Hallowfall is my most recent project, a 2D roguelike inspired by soulslike games. I chose the roguelike genre because it’s well suited for a solo developer. Its reusable content and system driven design present both creative opportunities and meaningful programming challenges. This project has been the perfect opportunity to establish the groundwork for core systems. My initial goals were to refine existing mechanics, improve code maintainability, and build a scalable foundation for roguelike gameplay. From procedural world to diverse abilities and adaptive enemy AI, I'm hoping that one day the game grows into a rich and challenging experience.
To handle growing complexity around the player character, I designed a scalable, state driven architecture centered on a Finite State Machine (FSM) managed by a PlayerController. The system emphasized clean separation of concerns and event driven communication, ensuring that each behavior existed in an isolated state while remaining decoupled, extensible, and responsive.
public interface IEntityState
{
void EnterState();
void ExitState();
void FrameUpdate();
void PhysicsUpdate();
}
public class PlayerState : IEntityState
{
protected PlayerStateEnum stateEnum;
protected PlayerController playerController;
protected PlayerConfig playerConfig;
protected PlayerStateMachine stateMachine;
protected PlayerSignalHub signalHub;
public PlayerState(PlayerController playerController, PlayerStateMachine stateMachine, PlayerConfig playerConfig, PlayerStateEnum stateEnum)
{
this.playerController = playerController;
this.stateMachine = stateMachine;
this.playerConfig = playerConfig;
this.stateEnum = stateEnum;
this.signalHub = playerController.PlayerSignalHub;
}
public virtual void EnterState() { }
public virtual void ExitState() { }
public virtual void FrameUpdate() { }
public virtual void PhysicsUpdate() { }
}
public class PlayerParryState : PlayerState
{
private PlayerParryShield parryShield;
private AudioClip [] parrySFX;
private float parryWindow;
private bool isParrySuccessful = false;
private bool canCounterParry = true;
private bool canParryProjectiles = false;
public bool CanCounterParry { get => canCounterParry; set => canCounterParry = value; }
public bool CanParryProjectiles { get => canParryProjectiles; set => canParryProjectiles = value; }
public PlayerParryState(PlayerController playerController, PlayerStateMachine stateMachine, PlayerConfig playerConfig, PlayerStateEnum stateEnum) : base(playerController, stateMachine, playerConfig, stateEnum)
{
this.stateEnum = PlayerStateEnum.Parry;
parrySFX = playerConfig.parrySFX;
parryWindow = playerConfig.parryWindow;
parryShield = playerController.ParryShield;
signalHub.OnActivatingParryShield += ActivateParryShield;
signalHub.OnEnemyParried += OnSuccessfulParry;
signalHub.OnEnemyParried += OnSuccessfulParry;
signalHub.OnParryEnd += OnParryHoldAnimEnd;
}
public override void EnterState()
{
playerController.IsParrying = true;
StartParry();
}
public override void ExitState()
{
playerController.IsParrying = false;
isParrySuccessful = false;
}
private void StartParry()
{
signalHub.OnTurningToMousePos?.Invoke();
signalHub.OnAnimBool?.Invoke("isParrying",true);
playerController.CoroutineRunner.RunCoroutine(StopParryCoroutine());
}
private IEnumerator StopParryCoroutine()
{
yield return new WaitForSeconds(parryWindow);
OnParryHoldAnimEnd();
}
private void OnParryHoldAnimEnd()
{
parryShield.BoxCollider.enabled = false;
if (!isParrySuccessful)
{
signalHub.OnAnimBool?.Invoke("isParrying", false);
signalHub.OnAnimBool?.Invoke("isParrySuccessful", false);
signalHub.OnStateTransitionBasedOnMovement?.Invoke(PlayerStateEnum.Parry);
}
else isParrySuccessful = false;
}
private void ActivateParryShield()
{
parryShield.BoxCollider.enabled = true;
}
private void OnSuccessfulParry(EnemyController enemyController, float parryDamage)
{
parryShield.BoxCollider.enabled = false;
signalHub.OnPlayRandomSFX?.Invoke(parrySFX, 1f);
if (CanCounterParry)
{
isParrySuccessful = true;
signalHub.OnAnimBool?.Invoke("isParrySuccessful", true);
}
}
}
/* The section managing state initialization, dependency injection, and input callbacks,
acting as the central hub for coordinating player systems.*/
public class PlayerController : MonoBehaviour
{
private void Start()
{
InitStats();
InjectDependencies();
stateMachine = new PlayerStateMachine(this);
stateMachine.InitAllStates();
GameManager.Instance.InitSkillsFromSkillTree();
}
private void Update()
{
stateMachine.CurrentState?.FrameUpdate();
ManageTimers();
}
private void FixedUpdate()
{
stateMachine.CurrentState?.PhysicsUpdate();
}
private void InjectDependencies()
{
playerInputHandler.Init(this);
playerMovementHandler.Init(this);
playerHealthBarHandler.Init(this);
playerHitHandler.Init(this);
playerAnimationHandler.Init(this);
playerSFXHandler.Init(this);
playerVFXHandler.Init(this);
}
//Input Callbacks
public void OnMoveInput(Vector2 dir)
{
playerMovementHandler.SetMovementInputDirection(dir);
}
public void OnSwordAttackInput()
{
signalHub.OnChangeState?.Invoke(PlayerStateEnum.SwordAttack);
stateMachine.PlayerSwordAttackState.TrySwordAttack();
}
public void OnDashInput()
{
if (CanDash)
{
signalHub.OnChangeState?.Invoke(PlayerStateEnum.Dash);
}
}
public void OnParryInput()
{
if (!isRolling && !isParrying)
{
signalHub.OnChangeState?.Invoke(PlayerStateEnum.Parry);
}
}
public void OnRollInput()
{
if (!isRolling && canRoll)
{
signalHub.OnChangeState?.Invoke(PlayerStateEnum.Roll);
}
}
}
public class PlayerSFXHandler : MonoBehaviour, IInitializeable
{
private AudioManager audioManager;
private PlayerController playerController;
private AudioClip[] groundRunSFX;
private AudioClip[] grassRunSFX;
private AudioClip[] stoneRunSFX;
public void Init(PlayerController playerController)
{
this.playerController = playerController;
audioManager = AudioManager.Instance;
groundRunSFX = playerController.PlayerConfig.groundSFX;
grassRunSFX = playerController.PlayerConfig.grassSFX;
stoneRunSFX = playerController.PlayerConfig.stoneSFX;
playerController.PlayerSignalHub.OnPlayerStep += PlayStepSound;
playerController.PlayerSignalHub.OnPlaySFX += PlaySFX;
playerController.PlayerSignalHub.OnPlayRandomSFX += PlayRandomSFX;
}
private void OnDisable()
{
playerController.PlayerSignalHub.OnPlayerStep -= PlayStepSound;
playerController.PlayerSignalHub.OnPlaySFX -= PlaySFX;
playerController.PlayerSignalHub.OnPlayRandomSFX -= PlayRandomSFX;
}
private void PlaySFX(AudioClip audioClip, float volume)
{
audioManager.PlaySFX(audioClip, playerController.GetPlayerPos(), volume);
}
private void PlayRandomSFX(AudioClip []audioClip, float volume)
{
audioManager.PlaySFX(audioClip, playerController.GetPlayerPos(), volume);
}
private void PlayStepSound()
{
switch (playerController.CurrentFloorType)
{
case FloorTypeEnum.Ground:
PlayRandomSFX(groundRunSFX, 0.25f);
break;
case FloorTypeEnum.Grass:
PlayRandomSFX(grassRunSFX, 0.25f);
break;
case FloorTypeEnum.Stone:
PlayRandomSFX(stoneRunSFX, 0.25f);
break;
}
}
}
The Procedural World Generation System is implemented using a dynamic zone based architecture, where the game world is divided into manageable zones that are procedurally generated, activated, and deactivated based on player proximity. Each zone is responsible for generating terrain, props, and gameplay elements according to defined profiles and procedural rules. This system was developed to optimize memory usage, ensure seamless exploration, and allow for unique, replayable game environments. The core idea revolves around a ZoneManager for high level management and a ZoneHandler for zone specific generation.
GeneratedZonesDic) for efficient retrieval of zone data.
[RequireComponent(typeof(CTicker))]
public class ZoneManager : MonoBehaviour
{
public static ZoneManager Instance { get; private set; }
[SerializeField] private int zoneSize = 40;
[SerializeField] private int zoneCellSize = 1;
[SerializeField] private float zoneBuffer = 25;
[SerializeField] private GameObject zonePrefab;
[SerializeField] private GameObject mainGrid;
[SerializeField] private Tilemap zoneConnectingGround;
[SerializeField] private ZoneLayoutProfile zoneLayoutProfile;
private GameObject player;
private int halfZoneSize;
private CTicker cTicker;
public Tilemap ZoneConnectingGround => zoneConnectingGround;
public Dictionary GeneratedZonesDic { get => generatedZonesDic;}
private Dictionary generatedZonesDic = new();
private void OnValidate()
{
// Validate required serialized fields
MyUtils.ValidateFields(this, zonePrefab, nameof(zonePrefab));
MyUtils.ValidateFields(this, mainGrid, nameof(mainGrid));
MyUtils.ValidateFields(this, zoneConnectingGround, nameof(zoneConnectingGround));
}
private void Awake()
{
if (Instance != null && Instance != this)
{
Destroy(gameObject);
return;
}
Instance = this;
cTicker = GetComponent();
halfZoneSize = zoneSize / 2;
}
private void Start()
{
player = GameManager.Instance.Player;
cTicker.CanTick = true;
TryGenerateZone(Vector2Int.zero, DirectionEnum.None);
cTicker.OnTickEvent += CheckForPlayerEdgeProximity;
}
public ZoneData FindZoneDataFromWorldPos(Vector3 worldPos)
{
Vector2Int zoneCoord = new Vector2Int(Mathf.RoundToInt(worldPos.x / zoneSize), Mathf.RoundToInt(worldPos.y / zoneSize));
if (generatedZonesDic.TryGetValue(zoneCoord, out ZoneData zoneData)) return zoneData;
return null;
}
public Cell FindCurrentCellFromWorldPos(Vector3 worldPos)
{
ZoneData zoneData = FindZoneDataFromWorldPos(worldPos);
if(zoneData == null) return null;
Cell result = zoneData.ZoneHandler.CellGrid.GetCellFromWorldPos(worldPos);
return result;
}
private void TryGenerateZone(Vector2Int centerCoord, DirectionEnum expansionDir)
{
if (generatedZonesDic.ContainsKey(centerCoord)) return;
Vector3Int newZoneWorldPos = FindZoneCenterPosition(centerCoord);
GameObject newZoneGO = Instantiate(zonePrefab, newZoneWorldPos, Quaternion.identity, mainGrid.transform);
var zoneData = new ZoneData(zoneCellSize, zoneSize, zoneSize, centerCoord, newZoneWorldPos, newZoneGO, expansionDir, zoneLayoutProfile);
generatedZonesDic.Add(centerCoord, zoneData);
var zoneHandler = newZoneGO.GetComponent();
zoneHandler.Init(zoneData);
}
private Vector2Int GetCurrentZoneCenterCoord()
{
Vector3 pos = player.transform.position;
return new Vector2Int(
Mathf.FloorToInt((pos.x + halfZoneSize) / zoneSize),
Mathf.FloorToInt((pos.y + halfZoneSize) / zoneSize)
);
}
public ZoneHandler GetCurrentZoneHandler()
{
return generatedZonesDic[GetCurrentZoneCenterCoord()].ZoneHandler;
}
private Vector3Int FindZoneCenterPosition(Vector2Int centerCoord)
{
return new Vector3Int(centerCoord.x * zoneSize, centerCoord.y * zoneSize, 0);
}
private void CheckForPlayerEdgeProximity()
{
Vector3 playerPos = player.transform.position;
Vector3Int currentZoneCenter = FindZoneCenterPosition(GetCurrentZoneCenterCoord());
Vector2Int[] allDirections = MyUtils.GetAllDirectionsVectorArray();
Dictionary directionDic = MyUtils.GetDirectionDicWithVectorKey();
foreach (Vector2Int dir in allDirections)
{
Vector2Int nextCoord = GetCurrentZoneCenterCoord() + dir;
Vector3Int nextZonePos = currentZoneCenter + ((Vector3Int)dir * halfZoneSize);
float distSqr = (nextZonePos - playerPos).sqrMagnitude;
bool withinBuffer = distSqr < zoneBuffer * zoneBuffer;
if (!generatedZonesDic.ContainsKey(nextCoord))
{
if (withinBuffer && directionDic.TryGetValue(dir, out DirectionEnum dirEnum))
{
ExpandZones(dirEnum);
}
}
else
{
var zone = generatedZonesDic[nextCoord];
if (!withinBuffer && zone.IsZoneFullyGenerated)
{
zone.zoneGO.SetActive(false);
}
else
{
zone.zoneGO.SetActive(true);
}
}
}
}
private void ExpandZones(DirectionEnum dirEnum)
{
Vector2Int currentCoord = GetCurrentZoneCenterCoord();
switch (dirEnum)
{
case DirectionEnum.Right:
TryGenerateZone(currentCoord + Vector2Int.right, DirectionEnum.Left);
break;
case DirectionEnum.Left:
TryGenerateZone(currentCoord + Vector2Int.left, DirectionEnum.Right);
break;
case DirectionEnum.Up:
TryGenerateZone(currentCoord + Vector2Int.up, DirectionEnum.Down);
break;
case DirectionEnum.Down:
TryGenerateZone(currentCoord + Vector2Int.down, DirectionEnum.Up);
break;
case DirectionEnum.UpRight:
TryGenerateZone(currentCoord + Vector2Int.up, DirectionEnum.DownLeft);
TryGenerateZone(currentCoord + new Vector2Int(1, 1), DirectionEnum.DownLeft);
break;
case DirectionEnum.UpLeft:
TryGenerateZone(currentCoord + Vector2Int.up, DirectionEnum.DownRight);
TryGenerateZone(currentCoord + new Vector2Int(-1, 1), DirectionEnum.DownRight);
break;
case DirectionEnum.DownRight:
TryGenerateZone(currentCoord + Vector2Int.down, DirectionEnum.UpLeft);
TryGenerateZone(currentCoord + new Vector2Int(1, -1), DirectionEnum.UpLeft);
break;
case DirectionEnum.DownLeft:
TryGenerateZone(currentCoord + Vector2Int.down, DirectionEnum.UpRight);
TryGenerateZone(currentCoord + new Vector2Int(-1, -1), DirectionEnum.UpRight);
break;
}
}
}
public class ZoneHandler : MonoBehaviour
{
private ZoneData zoneData;
private ZoneLayoutProfile zoneLayoutProfile;
private int cellSize;
private int zoneWidth;
private int zoneHeight;
private CellGrid cellGrid;
private readonly List listOfSubzoneBounds = new();
private List listOfPartitionedSubzoneBounds = new();
private Dictionary zoneOpenings = new();
private Tilemap groundZeroTilemap;
private Tilemap groundOneTilemap;
private Tilemap groundTwoTilemap;
private Tilemap boundsTilemap;
private Tilemap propsWithCollisionTilemap;
private Tilemap propsNoCollisionTilemap;
[SerializeField] private Tilemap treeTilemap;
public Tilemap GroundZeroTilemap => groundZeroTilemap;
public Tilemap GroundOneTilemap => groundOneTilemap;
public Tilemap GroundTwoTilemap => groundTwoTilemap;
public Tilemap PropsWithCollisionTilemap => propsWithCollisionTilemap;
public Tilemap PropsNoCollisionTilemap => propsNoCollisionTilemap;
public Tilemap BoundsTilemap => boundsTilemap;
public Tilemap TreeTilemap => treeTilemap;
public ZoneData ZoneData { get => zoneData; set => zoneData = value; }
public ZoneLayoutProfile ZoneLayoutProfile { get => zoneLayoutProfile; set => zoneLayoutProfile = value; }
public CellGrid CellGrid { get => cellGrid; }
private void OnValidate()
{
MyUtils.ValidateFields(this, propsWithCollisionTilemap, nameof(propsWithCollisionTilemap));
MyUtils.ValidateFields(this, propsNoCollisionTilemap, nameof(propsNoCollisionTilemap));
MyUtils.ValidateFields(this, groundZeroTilemap, nameof(groundZeroTilemap));
MyUtils.ValidateFields(this, groundOneTilemap, nameof(groundZeroTilemap));
MyUtils.ValidateFields(this, groundZeroTilemap, nameof(groundZeroTilemap));
MyUtils.ValidateFields(this, boundsTilemap, nameof(boundsTilemap));
MyUtils.ValidateFields(this, TreeTilemap, nameof(TreeTilemap));
}
public void Init(ZoneData zoneData)
{
this.zoneData = zoneData;
this.zoneLayoutProfile = this.zoneData.zoneLayoutProfile;
this.zoneWidth = this.zoneData.zoneWidth;
this.zoneHeight = this.zoneData.zoneHeight;
this.cellSize = this.zoneData.cellSize;
cellGrid = new CellGrid(this.cellSize, this.zoneWidth, this.zoneHeight);
cellGrid.InitializeGridCells(this.zoneData.centerPos);
StartCoroutine(GenerateZoneCoroutine());
}
private IEnumerator GenerateZoneCoroutine()
{
PopulateZoneWithPropBlocks(CellGrid, zoneLayoutProfile);
AddDefaultGroundTileForZone(zoneLayoutProfile);
yield return null;
yield return StartCoroutine(CellGrid.PaintAllBlocksCoroutine());
yield return StartCoroutine(CellGrid.PaintAllCellsCoroutine());
FlowFieldManager.Instance.UpdateFlowFieldFromZone(zoneData);
zoneData.IsZoneFullyGenerated = true;
}
private void PopulateZoneWithPropBlocks(CellGrid cellGrid, ZoneLayoutProfile zoneLayoutProfile)
{
Cell startCell = cellGrid.FindNextUnpartitionedCell(new Vector2Int(0, 0));
CreateAllSubZoneBounds(cellGrid, startCell);
//Turns big subZones to smalle blocks
listOfPartitionedSubzoneBounds = MyUtils.PerformeBinarySpacePartitioning(listOfSubzoneBounds, 8, 8);
InstantiatePropsBlocks(listOfPartitionedSubzoneBounds, zoneLayoutProfile);
}
private void CreateAllSubZoneBounds(CellGrid cellGrid, Cell startCell)
{
//Check along left edge
int h1 = 1;
for (int i = 1; i + startCell.GlobalCellCoord.y < cellGrid.CellPerCol; i++)
{
if (cellGrid.Cells[startCell.GlobalCellCoord.x, startCell.GlobalCellCoord.y + i].IsPartitioned)
break;
h1++;
}
//Check along bottom edge
int w1 = 1;
for (int i = 1; i + startCell.GlobalCellCoord.x < cellGrid.CellPerRow; i++)
{
if (cellGrid.Cells[startCell.GlobalCellCoord.x + i, startCell.GlobalCellCoord.y].IsPartitioned)
break;
w1++;
}
//Check along top edge
int w2 = 1;
for (int i = 1; i + startCell.GlobalCellCoord.x < cellGrid.CellPerRow; i++)
{
if (cellGrid.Cells[startCell.GlobalCellCoord.x + i, startCell.GlobalCellCoord.y + h1 - 1].IsPartitioned)
break;
w2++;
}
int width = Mathf.Min(w1, w2);
//Check along right edge
int h2 = 1;
for (int i = 1; i + startCell.GlobalCellCoord.y < cellGrid.CellPerCol; i++)
{
if (cellGrid.Cells[startCell.GlobalCellCoord.x + width - 1, startCell.GlobalCellCoord.y + i].IsPartitioned)
break;
h2++;
}
int height = Mathf.Min(h1, h2);
listOfSubzoneBounds.Add(new BoundsInt(new Vector3Int((int)startCell.GlobalCellPos.x, (int)startCell.GlobalCellPos.y, 0), new Vector3Int(width, height, 0)));
for (int x = startCell.GlobalCellCoord.x; x < startCell.GlobalCellCoord.x + width; x++)
{
for (int y = startCell.GlobalCellCoord.y; y < startCell.GlobalCellCoord.y + height; y++)
{
if (x >= 0 && x < cellGrid.CellPerRow && y >= 0 && y < cellGrid.CellPerCol)
cellGrid.Cells[x, y].MarkAsPartitioned();
}
}
Cell nextStartCell = cellGrid.FindNextUnpartitionedCell(new Vector2Int(startCell.GlobalCellCoord.x + width, startCell.GlobalCellCoord.y));
if (nextStartCell != null)
{
CreateAllSubZoneBounds(cellGrid, nextStartCell);
}
}
private void InstantiatePropsBlocks(List listOfPartitionedSubzoneBounds, ZoneLayoutProfile zoneLayoutProfile)
{
foreach (BoundsInt zoneBounds in listOfPartitionedSubzoneBounds)
{
PropsBlockEnum propsBlockEnum = GetPropsBlockTypeEnum(zoneLayoutProfile, zoneBounds);
if (propsBlockEnum != PropsBlockEnum.none)
{
GameObject go = Instantiate(zoneLayoutProfile.spawnablePropsBlock, zoneBounds.position, Quaternion.identity);
go.transform.parent = this.transform;
PropsBlock propsBlock = AddBlockComponent(go, propsBlockEnum);
propsBlock.Init(this, CellGrid, zoneBounds.position, zoneBounds, zoneLayoutProfile);
}
else
{
GameObject go = Instantiate(zoneLayoutProfile.spawnablePropsBlock, zoneBounds.position, Quaternion.identity);
go.transform.parent = this.transform;
}
}
}
private PropsBlockEnum GetPropsBlockTypeEnum(ZoneLayoutProfile zoneLayoutProfile, BoundsInt blockBounds)
{
if (zoneLayoutProfile.propsBlocksStructList.Count < 1) return PropsBlockEnum.none;
PropsBlockEnum propsBlockEnum = PropsBlockEnum.none;
int maxAttempts = 100;
int attempts = 0;
while (propsBlockEnum == PropsBlockEnum.none && attempts < maxAttempts)
{
PropsBlockStruct propsBlock = zoneLayoutProfile.propsBlocksStructList[
Random.Range(1, zoneLayoutProfile.propsBlocksStructList.Count)];
if (blockBounds.size.x >= propsBlock.minBlockSize.x &&
blockBounds.size.y >= propsBlock.minBlockSize.y &&
propsBlock.propsBlockEnum != PropsBlockEnum.none)
{
propsBlockEnum = propsBlock.propsBlockEnum;
}
attempts++;
}
return propsBlockEnum;
}
private PropsBlock AddBlockComponent(GameObject propsBlockGO, PropsBlockEnum propsBlockEnum)
{
switch (propsBlockEnum)
{
case PropsBlockEnum.cryptCluster:
return propsBlockGO.AddComponent();
case PropsBlockEnum.graveCluster:
return propsBlockGO.AddComponent();
case PropsBlockEnum.treeCluster:
return propsBlockGO.AddComponent();
case PropsBlockEnum.ritualCluster:
return propsBlockGO.AddComponent();
default:
return null;
}
}
private void AddDefaultGroundTileForZone(ZoneLayoutProfile zoneLayoutProfile)
{
CellPaint tilePaint = new CellPaint { tilemap = this.GroundZeroTilemap, tileBase = zoneLayoutProfile.defaultGroundTile };
CellGrid.LoopOverGrid((i, j) =>
{
CellGrid.Cells[i, j].AddToCellPaint(tilePaint);
});
}
private void DrawStraightLineOfTiles(Vector2Int beginningCellCoord, Vector2Int endCellCoord, CellPaint[] tilePaints)
{
Vector2Int delta = endCellCoord - beginningCellCoord;
if (delta.x == 0) // Vertical road
{
int yMin = Mathf.Min(beginningCellCoord.y, endCellCoord.y);
int yMax = Mathf.Max(beginningCellCoord.y, endCellCoord.y);
for (int y = yMin; y < yMax + 1; y++)
{
Vector3Int pos = TurnCellCoordToTilePos(beginningCellCoord.x, y);
//tilemap.SetTile(pos, tileBase);
CellGrid.Cells[beginningCellCoord.x, y].MarkAsOccupied();
CellGrid.Cells[beginningCellCoord.x, y].MarkAsPartitioned();
CellGrid.Cells[beginningCellCoord.x, y].AddToCellPaint(tilePaints);
}
}
else if (delta.y == 0) // Horizontal road
{
int xMin = Mathf.Min(beginningCellCoord.x, endCellCoord.x);
int xMax = Mathf.Max(beginningCellCoord.x, endCellCoord.x);
for (int x = xMin; x < xMax + 1; x++)
{
Vector3Int pos = TurnCellCoordToTilePos(x, beginningCellCoord.y);
//tilemap.SetTile(pos, tileBase);
CellGrid.Cells[x, beginningCellCoord.y].MarkAsOccupied();
CellGrid.Cells[x, beginningCellCoord.y].MarkAsPartitioned();
CellGrid.Cells[x, beginningCellCoord.y].AddToCellPaint(tilePaints);
}
}
}
private Vector3Int TurnCellCoordToTilePos(int x, int y)
{
return (Vector3Int)CellGrid.Cells[x, y].GlobalCellPos;
}
private void GenerateBoundsForTilemap()
{
Tilemap boundsTilemap = this.BoundsTilemap;
CellPaint[] tilePaints = { new CellPaint {tilemap = this.GroundOneTilemap, tileBase = zoneLayoutProfile.grassRuletile }, new CellPaint {ilemap = this.BoundsTilemap, tileBase = zoneLayoutProfile.fenceRuleTile } };
//Down
DrawStraightLineOfTiles(new Vector2Int(0, 0), new Vector2Int(CellGrid.CellPerRow - 1, 0), tilePaints);
//Up
DrawStraightLineOfTiles(new Vector2Int(0, CellGrid.CellPerCol - 1), new Vector2Int(CellGrid.CellPerRow - 1, CellGrid.CellPerCol - 1), tilePaints);
//Left
DrawStraightLineOfTiles(new Vector2Int(0, 0), new Vector2Int(0, CellGrid.CellPerCol - 1), tilePaints);
//Right
DrawStraightLineOfTiles(new Vector2Int(CellGrid.CellPerRow - 1, 0), new Vector2Int(CellGrid.CellPerRow - 1, CellGrid.CellPerCol - 1), tilePaints);
List dirs = MyUtils.GetAllDirectionEnumList();
int openingCount = Random.Range(2, 3);
// Always one horizontal + one vertical + one random
List openingDir = new List();
DirectionEnum horizontal = MyUtils.GetRandomHorizontalDirectionEnum();
DirectionEnum vertical = MyUtils.GetRandomVerticalDirectionEnum();
openingDir.Add(horizontal);
openingDir.Add(vertical);
dirs.Remove(horizontal);
dirs.Remove(vertical);
openingDir.Add(dirs[Random.Range(0, dirs.Count)]);
zoneOpenings = CreateOpeningsInZone(openingDir, CellGrid, boundsTilemap);
}
private Dictionary CreateOpeningsInZone(List openingDir, CellGrid cellGrid, Tilemap tilemap)
{
Dictionary zoneOpenings = new Dictionary();
// Openings for each selected side
foreach (DirectionEnum dir in openingDir)
{
int[] openingIndices = GenerateRandomOpeningCells();
Vector2Int[] openings = new Vector2Int[openingIndices.Length];
zoneOpenings.Add(dir, openings);
for (int i = 0; i < openingIndices.Length; i++)
{
Vector2Int cellCoord = Vector2Int.zero;
switch (dir)
{
case DirectionEnum.Down:
cellCoord = new Vector2Int(openingIndices[i], 0);
break;
case DirectionEnum.Up:
cellCoord = new Vector2Int(openingIndices[i], cellGrid.CellPerCol - 1);
break;
case DirectionEnum.Left:
cellCoord = new Vector2Int(0, openingIndices[i]);
break;
case DirectionEnum.Right:
cellCoord = new Vector2Int(cellGrid.CellPerRow - 1, openingIndices[i]);
break;
}
Vector3Int pos = TurnCellCoordToTilePos(cellCoord.x, cellCoord.y);
zoneOpenings[dir][i] = cellCoord;
cellGrid.Cells[cellCoord.x, cellCoord.y].MarkAsOccupied();
cellGrid.Cells[cellCoord.x, cellCoord.y].RemoveCellPaints();
}
}
return zoneOpenings;
}
private void GenerateRoads()
{
var opening1 = zoneOpenings.ElementAt(0);
var opening2 = zoneOpenings.ElementAt(1);
ConnectAllCenterJunctionPoints(opening1.Value, opening2.Value);
// Handle third direction if it exists
if (zoneOpenings.Count == 3)
{
var opening3 = zoneOpenings.ElementAt(2);
DirectionEnum thirdDir = opening3.Key;
if (thirdDir == DirectionEnum.Up || thirdDir == DirectionEnum.Down)
{
if (zoneOpenings.ContainsKey(DirectionEnum.Left))
ConnectAllCenterJunctionPoints(zoneOpenings[DirectionEnum.Left], opening3.Value);
if (zoneOpenings.ContainsKey(DirectionEnum.Right))
ConnectAllCenterJunctionPoints(zoneOpenings[DirectionEnum.Right], opening3.Value);
}
if (thirdDir == DirectionEnum.Left || thirdDir == DirectionEnum.Right)
{
if (zoneOpenings.ContainsKey(DirectionEnum.Up))
ConnectAllCenterJunctionPoints(zoneOpenings[DirectionEnum.Up], opening3.Value);
if (zoneOpenings.ContainsKey(DirectionEnum.Down))
ConnectAllCenterJunctionPoints(zoneOpenings[DirectionEnum.Down], opening3.Value);
}
}
}
private void ConnectAllCenterJunctionPoints(Vector2Int[] from, Vector2Int[] to)
{
//Tilemap stoneTilemap = ZoneManager.Instance.GroundOneTilemap;
Tilemap stoneTilemap = GroundOneTilemap;
int size = from.Length;
for (int i = 0; i < size; i++)
{
ConnectTwoJunctionPoints(stoneTilemap, from[i], to[i]); // Matching index
ConnectTwoJunctionPoints(stoneTilemap, from[i], to[2 - i]); // Flipped index
}
}
private void ConnectTwoJunctionPoints(Tilemap stoneTilemap, Vector2Int p1, Vector2Int p2)
{
Vector2Int junction = GetOpeningJunctionPoint(p1, p2);
CellPaint[] tilePaint = { new CellPaint {/* tilemap = ZoneManager.Instance.GroundOneTilemap*/ tilemap = this.GroundOneTilemap, tileBase = zoneLayoutProfile.stoneRoadRuleTile } };
DrawStraightLineOfTiles(p1, junction, tilePaint);
DrawStraightLineOfTiles(p2, junction, tilePaint);
}
private Vector2Int GetOpeningJunctionPoint(Vector2Int p1, Vector2Int p2)
{
Vector2Int result = new Vector2Int(p1.x, p2.y);
if (result.x == 0 || result.y == 0 || result.x >= 39 || result.y >= 39) return new Vector2Int(p2.x, p1.y);
return result;
}
private int[] GenerateRandomOpeningCells()
{
int rand = Random.Range(5, 35);
return new int[] { rand - 1, rand, rand + 1 };
}
}
public class CellGrid
{
protected int cellSize;
protected int gridWidth;
protected int gridHeight;
protected int cellPerRow;
protected int cellPerCol;
protected Cell[,] cells;
protected List blockPaintList = new();
public Cell[,] Cells => cells;
public int CellPerCol => cellPerCol;
public int CellPerRow => cellPerRow;
public int CellSize => cellSize;
public int GridWidth => gridWidth;
public int GridHeight => gridHeight;
public List BlockPaintList => blockPaintList;
public CellGrid(int cellSize, int gridWidth, int gridHeight)
{
this.cellSize = cellSize;
this.gridWidth = gridWidth;
this.gridHeight = gridHeight;
cellPerRow = Mathf.FloorToInt(gridWidth / cellSize);
cellPerCol = Mathf.FloorToInt(gridHeight / cellSize);
cells = new Cell[cellPerRow, cellPerCol];
}
public void InitializeGridCells(Vector3Int gridCenterWorldPos)
{
Vector3Int originWorldPos = gridCenterWorldPos - new Vector3Int(gridWidth / 2, gridHeight / 2, 0);
for (int i = 0; i < cellPerRow; i++)
{
for (int j = 0; j < cellPerCol; j++)
{
Vector2Int cellCoord = new(i, j);
cells[i, j] = new Cell(this, cellCoord, originWorldPos, cellSize);
}
}
}
public Cell FindNextUnpartitionedCell(Vector2Int startCellID)
{
for (int y = startCellID.y; y < cellPerCol; y++)
{
int xStart = (y == startCellID.y) ? startCellID.x : 0;
for (int x = xStart; x < cellPerRow; x++)
{
if (!cells[x, y].IsPartitioned)
return cells[x, y];
}
}
return null;
}
public Cell GetCellFromWorldPos(Vector3 worldPos)
{
Vector3Int cellPosOnGrid = new Vector3Int(Mathf.FloorToInt(worldPos.x), Mathf.FloorToInt(worldPos.y), 0);
Vector3Int cellCoord = cellPosOnGrid - cells[0,0].GlobalCellPos;
return cells[cellCoord.x, cellCoord.y];
}
public Cell GetCenterCellOfGrid() =>
cells[cellPerRow / 2, cellPerCol / 2];
public void AddToBlockPaints(Tilemap tilemap, TileBase tileBase, SubCellGrid cellGrid, CellGrid parentGrid)
{
BoundsInt blockBounds = new BoundsInt(
(Vector3Int)cellGrid.Cells[0, 0].GlobalCellCoord - new Vector3Int(parentGrid.CellPerRow / 2, parentGrid.CellPerCol / 2, 0),
new Vector3Int(cellGrid.CellPerRow, cellGrid.CellPerCol, 1)
);
BlockPaint blockPaint = new BlockPaint
{
tilemap = tilemap,
tileBase = tileBase,
blockBounds = blockBounds
};
parentGrid.BlockPaintList.Add(blockPaint);
}
private void PaintBlock(BlockPaint blockPaint)
{
if (blockPaint.tileBase == null || blockPaint.tilemap == null)
return;
BoundsInt bounds = blockPaint.blockBounds;
int width = bounds.size.x;
int height = bounds.size.y;
TileBase[] tiles = new TileBase[width * height];
for (int i = 0; i < tiles.Length; i++)
tiles[i] = blockPaint.tileBase;
blockPaint.tilemap.SetTilesBlock(bounds, tiles);
}
public IEnumerator PaintAllBlocksCoroutine()
{
int count = 0;
foreach (var block in BlockPaintList)
{
PaintBlock(block);
count++;
if (count >= 5)
{
count = 0;
yield return null;
}
}
}
public IEnumerator PaintAllCellsCoroutine()
{
int count = 0;
for (int j = 0; j < cellPerCol; j++)
{
for (int i = 0; i < cellPerRow; i++)
{
cells[i, j].PaintCell(this);
count++;
if (count >= 200)
{
count = 0;
yield return null;
}
}
}
}
public void PaintAllCells()
{
LoopOverGrid((i, j) => cells[i, j].PaintCell(this));
}
public GameObject TryInstantiatePermanentGameobjectOnTile(GameObject prefab, Vector2Int localCellCoord, Quaternion rotation, bool markOccupied, Transform parent = null)
{
if (!MyUtils.IsWithinArrayBounds(cellPerRow, cellPerCol, localCellCoord)) return null;
Cell cell = cells[localCellCoord.x, localCellCoord.y];
if (cell.IsOccupied) return null;
Vector3 pos = (Vector3)cell.GlobalCellPos + new Vector3(0.5f, 0.5f, 0);
GameObject go = UnityEngine.Object.Instantiate(prefab, pos, rotation);
if (parent != null) go.transform.parent = parent;
if (markOccupied) cell.MarkAsOccupied();
return go;
}
public GameObject TryInstantiateTempGameobjectOnTile(GameObject prefab, Vector2Int localCellCoord, Quaternion rotation, Transform parent = null)
{
if (!MyUtils.IsWithinArrayBounds(cellPerRow, cellPerCol, localCellCoord)) return null;
Vector3 pos = (Vector3)cells[localCellCoord.x, localCellCoord.y].GlobalCellPos + new Vector3(0.5f, 0.5f, 0);
GameObject go = UnityEngine.Object.Instantiate(prefab, pos, rotation);
if (parent != null) go.transform.parent = parent;
return go;
}
public void LoopOverGrid(Action action)
{
for (int j = 0; j < cellPerCol; j++)
{
for (int i = 0; i < cellPerRow; i++)
{
action(i, j);
}
}
}
}
public class Cell
{
private CellGrid parentGrid;
private CellFlowData cellFlowData;
private bool isOccupied = false;
private bool isPartitioned = false;
private float cellSize;
private Vector3Int globalCellPos = Vector3Int.zero;
private Vector2Int globalCellCoord = Vector2Int.zero;
private Vector2Int localCellCoord = Vector2Int.zero;
private HashSet cellPaintHashSet = new();
public bool IsOccupied => isOccupied;
public bool IsPartitioned => isPartitioned;
public bool IsWalkable => cellFlowData.isWalkable;
public float CellSize => cellSize;
public Vector2Int GlobalCellCoord => globalCellCoord;
public Vector3Int GlobalCellPos => globalCellPos;
public Vector2Int LocalCellCoord { get => localCellCoord; set => localCellCoord = value; }
public HashSet TilePaints => cellPaintHashSet;
public CellGrid ParentGrid => parentGrid;
public Vector2 FlowVect { get => cellFlowData.flowVector; set => cellFlowData.flowVector = value; }
public DirectionEnum FlowDir
{
get => cellFlowData.flowDirection;
set
{
cellFlowData.flowDirection = value;
cellFlowData.flowVector = MyUtils.GetVectorFromDir(value);
}
}
public int TotalCost => cellFlowData.totalCost;
public int DynamicCost { get => cellFlowData.dynamicCost; set => cellFlowData.dynamicCost = value; }
public int BaseCost { get => cellFlowData.baseCost; set => cellFlowData.baseCost = value; }
public bool HasEnemy => cellFlowData.hasEnemy;
public Cell(CellGrid parentGrid, Vector2Int globalCellCoord, Vector3Int gridPos, int cellSize)
{
this.parentGrid = parentGrid;
this.globalCellCoord = globalCellCoord;
this.cellSize = cellSize;
this.globalCellPos = gridPos + new Vector3Int(globalCellCoord.x * cellSize, globalCellCoord.y * cellSize, 0);
this.LocalCellCoord = globalCellCoord;
this.cellFlowData = new CellFlowData(true, false, DirectionEnum.None, Vector2.zero, 1, 0);
}
public void MarkAsOccupied() => isOccupied = true;
public void MarkAsUnoccupied() => isOccupied = false;
public void MarkAsPartitioned() => isPartitioned = true;
public void MarkAsUnwalkable() => cellFlowData.isWalkable = false;
public void MarkAsWalkable() => cellFlowData.isWalkable = true;
public void MarkOccupiedByEnemy()
{
if (!cellFlowData.hasEnemy)
{
cellFlowData.hasEnemy = true;
cellFlowData.dynamicCost += (int)CellFlowCost.hasEnemy;
}
}
public void MarkClearByEnemy()
{
if(cellFlowData.hasEnemy)
{
cellFlowData.hasEnemy = false;
cellFlowData.dynamicCost -= (int)CellFlowCost.hasEnemy;
}
}
public void AddToCellPaint(CellPaint[] tilePaints)
{
foreach (var tilePaint in tilePaints)
cellPaintHashSet.Add(tilePaint);
}
public void AddToCellPaint(CellPaint tilePaint)
{
cellPaintHashSet.Add(tilePaint);
}
public bool HasOccupiedNeighbor(CellGrid cellGrid)
{
foreach (var dir in MyUtils.GetAllDirectionsVectorArray())
{
Vector2Int neighborID = GlobalCellCoord + dir;
if (MyUtils.IsWithinArrayBounds(cellGrid.CellPerRow, cellGrid.CellPerCol, neighborID))
{
if (cellGrid.Cells[neighborID.x, neighborID.y].IsOccupied)
return true;
}
}
return false;
}
public bool HasOccupiedCardinalNeighbor(CellGrid cellGrid)
{
foreach (var dir in MyUtils.GetCardinalDirectionsVectorArray())
{
Vector2Int neighborID = GlobalCellCoord + dir;
if (MyUtils.IsWithinArrayBounds(cellGrid.CellPerRow, cellGrid.CellPerCol, neighborID))
{
if (cellGrid.Cells[neighborID.x, neighborID.y].IsOccupied)
return true;
}
}
return false;
}
public void PaintCell(CellGrid parentGrid)
{
foreach (var tilePaint in cellPaintHashSet)
{
if (!tilePaint.isOnGlobalTile)
{
Vector3Int offset = new Vector3Int(parentGrid.CellPerRow / 2, parentGrid.CellPerCol / 2, 0);
tilePaint.tilemap.SetTile((Vector3Int)GlobalCellCoord - offset, tilePaint.tileBase);
}
else
{
tilePaint.tilemap.SetTile(globalCellPos, tilePaint.tileBase);
}
}
}
public void PaintCell(Tilemap tilemap, TileBase tileBase, CellGrid parentGrid)
{
Vector3Int offset = new Vector3Int(parentGrid.CellPerRow / 2, parentGrid.CellPerCol / 2, 0);
Vector3Int tilePos = (Vector3Int)GlobalCellCoord - offset;
tilemap.SetTile(tilePos, tileBase);
}
public void RemoveCellPaints()
{
cellPaintHashSet.Clear();
}
public List GetAllNeighborCells()
{
List result = new();
foreach (var vect in MyUtils.GetAllDirectionsVectorList())
{
Vector2Int neighborCoord = globalCellCoord + vect;
if (MyUtils.IsWithinArrayBounds(parentGrid.Cells, neighborCoord))
result.Add(parentGrid.Cells[neighborCoord.x, neighborCoord.y]);
}
return result;
}
public List GetCardinalNeighborCells()
{
List| result = new();
foreach (var vect in MyUtils.GetCardinalDirectionsVectorList())
{
Vector2Int neighborCoord = globalCellCoord + vect;
if (MyUtils.IsWithinArrayBounds(parentGrid.Cells, neighborCoord))
result.Add(parentGrid.Cells[neighborCoord.x, neighborCoord.y]);
}
return result;
}
}
| | | |
The Enemy AI System mirrors the Player Architecture in its overall design, built on a foundation of Finite State Machine (FSM) control, event driven communication, modular components, and isolated systems. The key difference is in the way state transitions happen: enemies react to real time data such as player proximity, health thresholds, or environmental conditions, rather than relying on direct input handlers like the player. This makes enemy behavior responsive and adaptive.
// State Manages Attacks/Abilities that are assigned to the abilityList
public class EnemyAttackState : EnemyState
{
private EnemySignalHub signalHub;
private float attackDelay;
private List abilityList;
private List availaleAbilityList;
private BaseEnemyAbilitySO currentAbility;
public EnemyAttackState(EnemyController enemyController, EnemyStateMachine stateMachine, EnemyStateEnum stateEnum) : base(enemyController, stateMachine, stateEnum)
{
this.enemyController = enemyController;
this.enemyConfig = enemyController.EnemyConfig;
signalHub = enemyController.SignalHub;
abilityList = enemyController.GetListOfAllAbilities();
availaleAbilityList = new List(abilityList);
currentAbility = MyUtils.GetRandomRef(availaleAbilityList);
signalHub.OnAbilityFinished += (value) => { EndAttack(); };
signalHub.OnAbilityStart += SetupNextAbility;
}
public override void EnterState()
{
if (enemyController.IsAttackDelayOver && availaleAbilityList.Count > 0)
{
enemyController.CanMove = false;
enemyController.CanAttack = false;
enemyController.IsAttacking = true;
signalHub.OnAbilityStart.Invoke(currentAbility);
enemyController.CoroutineRunner.RunCoroutine(PutAbilityOnCooldownCoroutine(currentAbility));
}
else stateMachine.ChangeState(EnemyStateEnum.Idle);
}
public override void ExitState()
{
enemyController.CanMove = true;
enemyController.CanAttack = true;
enemyController.IsAttacking = false;
currentAbility?.EndAbility(enemyController);
}
public void EndAttack()
{
signalHub.OnAbilityAnimFrame -= currentAbility.ActionOnAnimFrame;
signalHub.OnAbilityFinished -= currentAbility.EndAbility;
currentAbility = null;
TrySetcurrentAbiliy();
enemyController.CoroutineRunner.RunCoroutine(AttackDelayCoroutine());
stateMachine.ChangeState(EnemyStateEnum.Idle);
}
private void SetupNextAbility(BaseEnemyAbilitySO ability)
{
signalHub.OnAbilityAnimFrame += ability.ActionOnAnimFrame;
signalHub.OnAbilityFinished += ability.EndAbility;
ability.ExecuteAbility(enemyController);
}
private IEnumerator PutAbilityOnCooldownCoroutine(BaseEnemyAbilitySO ability)
{
availaleAbilityList.Remove(ability);
yield return new WaitForSeconds(ability.cooldown);
availaleAbilityList.Add(ability);
TrySetcurrentAbiliy();
}
private IEnumerator AttackDelayCoroutine()
{
attackDelay = Random.Range(enemyConfig.minAttackDelay, enemyConfig.maxAttackDelay + 0.1f);
enemyController.IsAttackDelayOver = false;
yield return new WaitForSeconds(attackDelay);
enemyController.IsAttackDelayOver = true;
}
private void TrySetcurrentAbiliy()
{
if (availaleAbilityList.Count > 0)
{
BaseEnemyAbilitySO abilityWithBiggestRange = null;
foreach (var ability in availaleAbilityList)
{
if(abilityWithBiggestRange == null ) abilityWithBiggestRange = ability;
else if (ability.range > abilityWithBiggestRange.range ) abilityWithBiggestRange = ability;
}
currentAbility = abilityWithBiggestRange;
}
}
private bool IsEnemyInAttackRange()
{
return Vector2.Distance(enemyController.PlayerController.GetPlayerPos(), enemyController.GetEnemyPos()) <= currentAbility.range;
}
private bool IsEnemyAbleToAttack()
{
return (enemyController.CanAttack && !enemyController.IsAttacking && enemyController.IsAttackDelayOver);
}
public bool CanChangeToAttackState()
{
return IsEnemyInAttackRange() && IsEnemyAbleToAttack() && currentAbility != null;
}
public bool CanChasePlayerToAttack()
{
return IsEnemyAbleToAttack() && currentAbility != null;
}
}
public class EnemyMeleeStrike : BaseEnemyAbilitySO
{
public EnemyAttackZone attackZonePrefab;
private EnemyAttackZone attackZone;
public int strikeDamage;
public int parryDamage;
public override void ExecuteAbility(EnemyController enemy)
{
enemy.SignalHub.OnAnimBool?.Invoke(animCondition, true);
attackZone = Instantiate(attackZonePrefab,enemy.GetEnemyPos(),Quaternion.identity);
SetupAttackZone(attackZone.gameObject,enemy);
attackZone.Init(new EnemyMeleeStrikeData { owner = enemy, strikeDamage = this.strikeDamage, parryDamage = this.parryDamage });
enemy.SignalHub.OnPlayRandomSFX?.Invoke(abilitySFX, 0.075f);
}
public override void ActionOnAnimFrame(EnemyController enemy)
{
if (attackZone != null)
{
attackZone.TryHitTarget(enemy);
attackZone = null;
}
}
public override void EndAbility(EnemyController enemy)
{
enemy.SignalHub.OnAnimBool?.Invoke(animCondition, false);
}
private void SetupAttackZone(GameObject attackZoneGO, EnemyController enemyController)
{
attackZoneGO.transform.parent = enemyController.transform;
Vector3 dir = (enemyController.PlayerController.GetPlayerPos() - enemyController.GetEnemyPos()).normalized;
float angle = Mathf.Atan2(dir.y, dir.x) * Mathf.Rad2Deg;
attackZoneGO.transform.SetPositionAndRotation(enemyController.GetEnemyPos() + (dir / 2), Quaternion.Euler(0, 0, angle + 180));
}
}
public struct EnemyMeleeStrikeData
{
public EnemyController owner;
public int strikeDamage;
public int parryDamage;
}
The Enemy Pathfinding has evolved with the game. I felt it deserved a dedicated section to explain my thoughts and efforts behind it. Initially, I used the built in Unity NavMesh to handle enemy pathfinding. However, after completing the first iteration of the World Generation System, I realized that NavMesh would need to be baked at runtime dynamically, causing noticeable performance issues. After exploring some workarounds, I developed a custom pathfinding system using Flow Field Pathfinding. By modifying my CellGrid system from the World Generation section, I successfully integrated the new pathfinding solution.
public class FlowFieldGenerator
{
private const int COST_UNVISITED = (int)CellFlowCost.unVisited;
private const int COST_UNWALKABLE = (int)CellFlowCost.unWalkable;
private const int COST_TARGET = (int)CellFlowCost.target;
public void GenerateFlowFieldOnTargetZone(CellGrid cellGrid, Vector3 targetPos)
{
Queue flowFieldCellQueue = new();
Cell currentFieldTargetCell = cellGrid.GetCellFromWorldPos(targetPos);
SetInitialBaseFlowCosts(cellGrid, currentFieldTargetCell, flowFieldCellQueue);
ApplyCostsForPlayerZone(flowFieldCellQueue, currentFieldTargetCell);
AssignFlowAllDirectionsOnPlayerZone(cellGrid);
}
public void GenerateFlowFieldOnNonePlayerZone(CellGrid cellGrid, DirectionEnum dirToPlayerZone)
{
Queue flowFieldCellQueue = new();
FindAllEdgeCells(cellGrid, flowFieldCellQueue, dirToPlayerZone);
ApplyCostsForNonePlayerZone(flowFieldCellQueue);
AssignFlowAllDirectionsOnNonePlayerZone(cellGrid, dirToPlayerZone);
}
private void FindAllEdgeCells(CellGrid cellGrid, Queue flowFieldCellQueue, DirectionEnum dirToPlayerZone)
{
cellGrid.LoopOverGrid((i, j) =>
{
Cell currentCell = cellGrid.Cells[i, j];
if (!currentCell.IsWalkable)
{
currentCell.BaseCost = COST_UNWALKABLE;
}
else currentCell.BaseCost = COST_UNVISITED;
});
switch (dirToPlayerZone)
{
case DirectionEnum.Right:
FindRightEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.Left:
FindLeftEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.Up:
FindTopEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.Down:
FindBottomEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.UpRight:
FindTopEdgeTargetCells(cellGrid, flowFieldCellQueue);
FindRightEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.UpLeft:
FindTopEdgeTargetCells(cellGrid, flowFieldCellQueue);
FindLeftEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.DownRight:
FindBottomEdgeTargetCells(cellGrid, flowFieldCellQueue);
FindRightEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
case DirectionEnum.DownLeft:
FindBottomEdgeTargetCells(cellGrid, flowFieldCellQueue);
FindLeftEdgeTargetCells(cellGrid, flowFieldCellQueue);
break;
}
}
private void SetInitialBaseFlowCosts(CellGrid cellGrid, Cell targetCell, Queue flowFieldCellQueue)
{
cellGrid.LoopOverGrid((i, j) =>
{
Cell currentCell = cellGrid.Cells[i, j];
if (!currentCell.IsWalkable)
{
currentCell.BaseCost = COST_UNWALKABLE;
}
else
{
currentCell.BaseCost = COST_UNVISITED;
}
}
);
targetCell.BaseCost = COST_TARGET;
flowFieldCellQueue.Enqueue(targetCell);
}
private void ApplyCostsForPlayerZone(Queue flowFieldCellQueue, Cell targetCell)
{
List neighborList = new(8);
while (flowFieldCellQueue.Count > 0)
{
Cell currentCell = flowFieldCellQueue.Dequeue();
neighborList.Clear();
neighborList.AddRange(currentCell.GetAllNeighborCells());
foreach (Cell neighbor in neighborList)
{
if (neighbor.IsWalkable && neighbor.BaseCost == COST_UNVISITED)
{
Vector2 toNeighbor = (Vector3)(neighbor.GlobalCellPos - targetCell.GlobalCellPos);
float angle = Mathf.Atan2(toNeighbor.y, toNeighbor.x);
float snapped = Mathf.Round(angle / (Mathf.PI / 2)) * (Mathf.PI / 2);
if (Mathf.Abs(angle - snapped) > Mathf.PI / 12f)
{
neighbor.BaseCost = currentCell.BaseCost + 2;
}
else
{
neighbor.BaseCost = currentCell.BaseCost + 1;
}
flowFieldCellQueue.Enqueue(neighbor);
}
}
}
}
private void ApplyCostsForNonePlayerZone(Queue flowFieldCellQueue)
{
List neighborList = new(4);
while (flowFieldCellQueue.Count > 0)
{
Cell currentCell = flowFieldCellQueue.Dequeue();
neighborList.Clear();
neighborList.AddRange(currentCell.GetAllNeighborCells());
foreach (Cell neighbor in neighborList)
{
if (neighbor.IsWalkable && neighbor.BaseCost == COST_UNVISITED)
{
neighbor.BaseCost = currentCell.BaseCost + 1;
flowFieldCellQueue.Enqueue(neighbor);
}
}
}
}
private void AssignFlowAllDirectionsOnPlayerZone(CellGrid cellGrid)
{
cellGrid.LoopOverGrid((i, j) =>
{
cellGrid.Cells[i, j].FlowDir = FindFlowForPlayerDirection(cellGrid.Cells[i, j]);
}
);
}
private void AssignFlowAllDirectionsOnNonePlayerZone(CellGrid cellGrid, DirectionEnum directionEnum)
{
cellGrid.LoopOverGrid((i, j) =>
{
cellGrid.Cells[i, j].FlowDir = FindFlowDirectionForNonePlayerZone(cellGrid.Cells[i, j], directionEnum);
}
);
}
private DirectionEnum FindFlowForPlayerDirection(Cell currentCell)
{
if (currentCell.TotalCost == COST_TARGET) return DirectionEnum.None;
List cheapestNeighbors = new();
int minCost = int.MaxValue;
foreach (Cell neighbor in currentCell.GetAllNeighborCells())
{
if (!neighbor.IsWalkable) continue;
if (neighbor.TotalCost < minCost)
{
cheapestNeighbors.Clear();
minCost = neighbor.TotalCost;
cheapestNeighbors.Add(neighbor);
}
else if (neighbor.TotalCost == minCost)
{
cheapestNeighbors.Add(neighbor);
}
}
List validNeighborsWithEnemy = cheapestNeighbors.Where(cell => !cell.HasEnemy).ToList();
if (cheapestNeighbors.Count == 0) return DirectionEnum.None;
Cell selected = cheapestNeighbors[/*Random.Range(0, validNeighborsWithEnemy.Count)*/ 0];
Vector2Int dir = selected.GlobalCellCoord - currentCell.GlobalCellCoord;
return MyUtils.GetDirFromVector(dir);
}
private DirectionEnum FindFlowDirectionForNonePlayerZone(Cell currentCell, DirectionEnum directionEnum)
{
if (currentCell.TotalCost == COST_TARGET) return directionEnum;
List cheapestNeighbors = new();
int minCost = int.MaxValue;
foreach (Cell neighbor in currentCell.GetAllNeighborCells())
{
if (!neighbor.IsWalkable) continue;
if (neighbor.TotalCost < minCost)
{
cheapestNeighbors.Clear();
minCost = neighbor.TotalCost;
cheapestNeighbors.Add(neighbor);
}
else if (neighbor.TotalCost == minCost)
{
cheapestNeighbors.Add(neighbor);
}
}
List validNeighborsWithEnemy = cheapestNeighbors.Where(cell => !cell.HasEnemy).ToList();
if (validNeighborsWithEnemy.Count == 0) return DirectionEnum.None;
Cell selected = validNeighborsWithEnemy[Random.Range(0, validNeighborsWithEnemy.Count)];
Vector2Int dir = selected.GlobalCellCoord - currentCell.GlobalCellCoord;
return MyUtils.GetDirFromVector(dir);
}
private void FindRightEdgeTargetCells(CellGrid grid, Queue flowFieldCellQueue)
{
int x = grid.CellPerRow - 1;
for (int y = 0; y < grid.CellPerCol; y++)
{
if (!grid.Cells[x, y].IsWalkable) continue;
grid.Cells[x, y].BaseCost = COST_TARGET;
flowFieldCellQueue.Enqueue(grid.Cells[x, y]);
}
}
private void FindLeftEdgeTargetCells(CellGrid grid, Queue flowFieldCellQueue)
{
int x = 0;
for (int y = 0; y < grid.CellPerCol; y++)
{
if (!grid.Cells[x, y].IsWalkable) continue;
grid.Cells[x, y].BaseCost = COST_TARGET;
flowFieldCellQueue.Enqueue(grid.Cells[x, y]);
}
}
private void FindTopEdgeTargetCells(CellGrid grid, Queue flowFieldCellQueue)
{
int y = grid.CellPerCol - 1;
for (int x = 0; x < grid.CellPerRow; x++)
{
if (!grid.Cells[x, y].IsWalkable) continue;
grid.Cells[x, y].BaseCost = COST_TARGET;
flowFieldCellQueue.Enqueue(grid.Cells[x, y]);
}
}
private void FindBottomEdgeTargetCells(CellGrid grid, Queue| flowFieldCellQueue)
{
int y = 0;
for (int x = 0; x < grid.CellPerRow; x++)
{
if (!grid.Cells[x, y].IsWalkable) continue;
grid.Cells[x, y].BaseCost = COST_TARGET;
flowFieldCellQueue.Enqueue(grid.Cells[x, y]);
}
}
}
| | | | | | | | | | | | | | | |
public class FlowFieldManager : MonoBehaviour
{
private static FlowFieldManager instance;
public static FlowFieldManager Instance { get { return instance; } }
private ZoneManager zoneManager;
private FlowFieldGenerator flowFieldGenerator;
private ZoneData currentTargetZoneData;
private ZoneHandler currentTargetZoneHandler;
private bool canVisualizeFlowField = false;
private readonly HashSet cellsOccupiedByEnemy = new();
private void Awake()
{
if (instance != null && instance != this)
{
Destroy(instance.gameObject);
}
instance = this;
}
private void Start()
{
flowFieldGenerator = new FlowFieldGenerator();
zoneManager = ZoneManager.Instance;
}
public Vector2 RequestNewFlowDir(Cell currentCell, Cell lastCell)
{
if (lastCell != currentCell)
{
if (cellsOccupiedByEnemy.Contains(lastCell))
{W
cellsOccupiedByEnemy.Remove(lastCell);
}
}
if (!cellsOccupiedByEnemy.Contains(currentCell))
{
cellsOccupiedByEnemy.Add(currentCell);
}
return currentCell.FlowVect.normalized;
}
public void UpdateFlowField(Vector3 targetPos)
{
ZoneData zoneData = zoneManager.FindZoneDataFromWorldPos(targetPos);
if (zoneData == null) return;
if (currentTargetZoneData != zoneData)
{
currentTargetZoneData = zoneData;
currentTargetZoneHandler = currentTargetZoneData.ZoneHandler;
foreach (KeyValuePair pair in zoneManager.GeneratedZonesDic)
{
if (pair.Value != currentTargetZoneData) UpdateFlowFieldFromZone(pair.Value);
}
}
flowFieldGenerator.GenerateFlowFieldOnTargetZone(currentTargetZoneHandler.CellGrid, targetPos);
if (canVisualizeFlowField) GridSystemDebugger.Instance.VisualizeCellFlowDirection(currentTargetZoneHandler.CellGrid, currentTargetZoneData.centerCoord);
}
public void UpdateFlowFieldFromZone(ZoneData zoneData)
{
if (currentTargetZoneData != null)
{
DirectionEnum direEnumToPlayer = MyUtils.FindDirectionEnumBetweenTwoPoints(new Vector2Int(zoneData.centerPos.x, zoneData.centerPos.y), new Vector2Int(currentTargetZoneData.centerPos.x, currentTargetZoneData.centerPos.y));
ZoneHandler zoneHandler = zoneData.ZoneHandler;
flowFieldGenerator.GenerateFlowFieldOnNonePlayerZone(zoneHandler.CellGrid, direEnumToPlayer);
if (canVisualizeFlowField) GridSystemDebugger.Instance.VisualizeCellFlowDirection(zoneHandler.CellGrid, zoneData.centerCoord);
}
}
public void ToggleCellDebuger()
{
if (canVisualizeFlowField)
{
canVisualizeFlowField = false;
GridSystemDebugger.Instance.DisableAllVisuals();
}
else if (!canVisualizeFlowField)
{
canVisualizeFlowField = true;
GridSystemDebugger.Instance.EnableAllVisuals();
}
}
}
|
// This function calculates the movement direction vector for enemy
private void FindNextMovementDirection()
{
flowRequestTimer += Time.deltaTime;
if (flowRequestTimer >= flowRequestDelay || hasNotBeenOnFlow)
{
lastCell = zoneManager.FindCurrentCellFromWorldPos(lastPos);
currentCell = zoneManager.FindCurrentCellFromWorldPos(enemyController.transform.position);
Vector2 newDir = (flowFieldManager.RequestNewFlowDir(currentCell, lastCell) + CalculateRepulsionForce()) .normalized;
if (newDir != Vector2.zero)
{
nextMoveDir = newDir;
lastPos = enemyTransform.position;
hasNotBeenOnFlow = false;
flowRequestTimer = 0;
}
}
}
//Later on this function is used to move the enemy using the calculated direction vector
public void Move(Vector2 movementDirection, float speed)
{
Vector2 targetPos = Rb.position + (speed * Time.deltaTime * movementDirection);
Vector2 smoothPos = Vector2.Lerp(Rb.position, targetPos, 1f);
Rb.MovePosition(smoothPos);
}
private Vector2 CalculateRepulsionForce()
{
Vector2 repulsion = Vector2.zero;
float repulsionStrength = 0.2f;
float detectionRadius = 1.0f;
foreach (EnemyController otherEnemy in enemyController.Detector.DetectNearbyGenericTargetsOnParent("EnemyCollider", enemyController.GetEnemyPos(), layerMask, detectionRadius))
{
if (otherEnemy == enemyController) continue;
Vector2 diff = (Vector2)(transform.position - otherEnemy.transform.position);
float distance = diff.magnitude;
if(distance < detectionRadius && distance >0.01f)
{
repulsion += diff.normalized / distance;
}
}
return repulsion * repulsionStrength;
}