using System; using UnityEngine; namespace Marro.PacManUdon { enum PacManGhostType { Caught, Scared, ScaredWhite, Blinky, Pinky, Inky, Clyde, Special, } enum PacManGhostAnimatorState { Normal, Caught, Scared, ScaredWhite, Special } public enum PacManGhostState { Normal, CaughtScore, Returning, Entering, Home, Exiting, WaitingForStart, } public enum PacManGhostStartState { Outside, TargetingIdlePosition1, TargetingIdlePosition2 } [RequireComponent(typeof(Renderer))] [RequireComponent(typeof(Animator))] public class Ghost : GridMover { [SerializeField] private PacManGhostType ghostType; [SerializeField] private PacManGhostStartState startState; [SerializeField] private GameObject targetIndicator; // External references private GhostManager ghostManager; private CollisionManager collisionManager; private Animator animator; private new Renderer renderer; private PacMan pacMan; private Ghost blinky; private ScoreBonusDisplay scoreBonusDisplay; private Vector2 startPosition; private Vector2 homePosition; private Vector2 idlePosition1; private Vector2 idlePosition2; private Vector2 cornerPosition; // Pathfinding private Vector2 target; private bool horizontalOnly; private bool inTunnel; private int rngState; private bool turnAroundSoon; private float speed; // State private PacManGhostState ghostState; private bool isScared; private bool whiteScared; private bool scattering; private PacManGhostFrozenState frozenState; // Home private bool offGrid; private int housePelletCounter; private bool housePelletCounterActive; private int housePelletCounterLimit; // Cutscene private bool kinematic; private bool specialLook; private bool followingPredefinedPath; private Direction[] predefinedPath; private int predefinedPathIndex; public bool IsScared => isScared; public int Index { get; private set; } private readonly int animatorKeyDirection = Animator.StringToHash("Direction"); private readonly int animatorKeyGhostType = Animator.StringToHash("GhostType"); public void Initialize(CollisionManager collisionManager, PacMan pacMan, Ghost blinky, Transform startTransform, Vector2 homePosition, Vector2 idlePosition1, Vector2 idlePosition2, Vector2 cornerPosition, int index) { ghostManager = transform.parent.GetComponent(); animator = GetComponent(); renderer = GetComponent(); this.collisionManager = collisionManager; this.pacMan = pacMan; this.blinky = blinky; this.homePosition = homePosition; this.idlePosition1 = idlePosition1; this.idlePosition2 = idlePosition2; this.cornerPosition = cornerPosition; scoreBonusDisplay = transform.Find("ScoreBonusDisplay").gameObject.GetComponent(); scoreBonusDisplay.Initialize(); startPosition = startTransform.localPosition; frozenState = PacManGhostFrozenState.Frozen; Index = index; targetIndicator.transform.parent = transform.parent; } public void Reset() { // Debug.Log($"{gameObject} Reset!"); SetPosition(startPosition); offGrid = true; isScared = false; inTunnel = false; kinematic = false; followingPredefinedPath = false; turnAroundSoon = false; specialLook = false; rngState = 1; ghostState = PacManGhostState.WaitingForStart; switch (startState) { case PacManGhostStartState.TargetingIdlePosition1: SetTargetDirection(Direction.Up); break; case PacManGhostStartState.TargetingIdlePosition2: SetTargetDirection(Direction.Down); break; default: SetTargetDirection(Direction.Left); break; } UpdateSpeed(); // Debug.Log($"{gameObject} reset with state: {state}, target: {target}, offGrid: {offGrid}"); } public override void SyncedUpdate() { if (frozenState == PacManGhostFrozenState.Frozen || (frozenState == PacManGhostFrozenState.FrozenIfNotCaught && ghostState != PacManGhostState.Returning && ghostState != PacManGhostState.Entering)) { return; } Vector2 position = GetPosition(); Vector2 nextPosition = GetNextPosition(position, directionVectors[(int)direction], speed, networkManager.SyncedDeltaTime); nextPosition = ProcessNextPosition(position, nextPosition); SetPosition(nextPosition); } private Vector2 ProcessNextPosition(Vector2 position, Vector2 nextPosition) { if (turnAroundSoon && ghostState == PacManGhostState.Normal && CrossesTileCenter(position, nextPosition, direction)) { var newDirection = GetInverseDirection(direction); SetDirectionAndTargetDirection(newDirection); turnAroundSoon = false; return nextPosition; } if (kinematic) { return nextPosition; } if (ghostState == PacManGhostState.WaitingForStart) { switch (startState) { case PacManGhostStartState.TargetingIdlePosition1: SetOffGridTarget(idlePosition1, false); ghostState = PacManGhostState.Entering; break; case PacManGhostStartState.TargetingIdlePosition2: SetOffGridTarget(idlePosition2, false); ghostState = PacManGhostState.Entering; break; default: ghostState = PacManGhostState.Exiting; break; } OffGridTargetReached(); } if (offGrid || ghostState == PacManGhostState.Returning) { PerformOffgridRelatedMovement(position, ref nextPosition); } if (!offGrid) { if (followingPredefinedPath) { ProcessPredefinedPath(position, ref nextPosition); } else if (CrossesTileCenter(position, nextPosition, direction)) { if (targetDirection != direction) { ApplyTargetDirection(position, out nextPosition); } else { TryToQueueTurn(position); } } } if (CrossesTileBorder(position, nextPosition, direction)) { var inTunnel = collisionManager.GhostMoveToTile(nextPosition, Index); if (inTunnel != this.inTunnel) { this.inTunnel = inTunnel; UpdateSpeed(); } } return nextPosition; } private Vector2 PerformOffgridRelatedMovement(Vector2 position, ref Vector2 nextPosition) { bool XAxisAlligned = CheckAndAllignToTargetX(position, nextPosition, target); bool YAxisAlligned = CheckAndAllignToTargetY(position, nextPosition, target); if (offGrid) { if (XAxisAlligned) { nextPosition.x = target.x; } if (YAxisAlligned) { nextPosition.y = target.y; } } if (XAxisAlligned && YAxisAlligned) { if (!offGrid) { nextPosition = target; } OffGridTargetReached(); // Debug.Log($"{gameObject} reached offGridTarget {target} at position {position}, new state {state}, offGrid is now {offGrid}, new direction: {direction}"); } if ((XAxisAlligned || YAxisAlligned) && offGrid) { SetDirectionAndTargetDirection(GetOffGridDirectionToTarget(nextPosition, target, direction)); // Debug.Log($"{gameObject} Alligned X Axis: {XAxisAlligned}, Y Axis: {YAxisAlligned} with position: {position}, new nextPosition: {nextPosition}, new target: {target}, now moving in direction {direction}"); // nextPosition = GridMover.GetNextPosition(position, direction, speed); } return nextPosition; } private void TryToQueueTurn(Vector2 position) { var upcomingGridPosition = PositionToGrid(position + directionVectors[(int)direction]); var availableDirections = collisionManager.GetAvailableDirections(upcomingGridPosition); if ((availableDirections & (int)PacManCollisionInfoType.NoTurn) != 0) { return; } if (!isScared && (availableDirections & (int)PacManCollisionInfoType.HorizontalOnly) != 0) { availableDirections &= 0b1100; } availableDirections &= ~(int)GetInverseDirection(direction) & 0b1111; // Not allowed to turn around, also filter flags other than direction if (availableDirections == 0) { return; } Direction newDirection; if (isSingleBitSet[availableDirections]) { newDirection = (Direction)availableDirections; } else { target = GetGridTarget(upcomingGridPosition); targetIndicator.transform.localPosition = target; newDirection = GetGridDirectionToTargetGreedy(availableDirections, upcomingGridPosition, target); } if (newDirection == direction) { return; } SetTargetDirection(newDirection); } // Static fields are not yet supported on user-defined types private readonly bool[] isSingleBitSet = new bool[] { //0000,0001, 0010, 0011 , 0100, 0101 , 0110 , 0111 , 1000, 1001 , 1010 , 1011 , 1100 , 1101 , 1110 , 1111 false, true, true, false, true, false, false, false, true, false, false, false, false, false, false, false }; private void ApplyTargetDirection(Vector2 position, out Vector2 nextPosition) { var gridPosition = PositionToGrid(position); nextPosition = GetNextPosition(gridPosition, directionVectors[(int)targetDirection], speed, networkManager.SyncedDeltaTime); SetDirection(targetDirection); } private void ProcessPredefinedPath(Vector2 position, ref Vector2 nextPosition) { if (CrossesTileCenter(position, nextPosition, direction)) { // Find the next valid direction which isn't Vector2.zero int nextValidDirectionIndex = predefinedPathIndex; while (predefinedPath[nextValidDirectionIndex] == Direction.Zero) { nextValidDirectionIndex += 1; } if (!collisionManager.IsWallUpcoming(nextPosition, directionVectors[(int)predefinedPath[nextValidDirectionIndex]])) { // If we're at a Vector2.zero, we skip applying the direction and only increment. if (nextValidDirectionIndex == predefinedPathIndex) { SetDirectionAndTargetDirection(predefinedPath[nextValidDirectionIndex]); nextPosition = PositionToGrid(nextPosition) + directionVectors[(int)direction] * 0.01f; // Check if we've reached the end of the path, which includes making sure the path doesn't end on Vector2.zero do { nextValidDirectionIndex += 1; if (nextValidDirectionIndex >= predefinedPath.Length) { followingPredefinedPath = false; break; } } while (predefinedPath[nextValidDirectionIndex] == Direction.Zero); } // ghostManager.gameStateManager.statusDisplay.SetDebugText(1, predefinedPathIndex.ToString()); predefinedPathIndex++; } } } private Vector2 GetGridTarget(Vector2 gridPosition) { if (isScared) { return gridPosition + directionVectors[(int)cardinalDirections[PseudoRNG() % 4]]; } if (ghostState == PacManGhostState.Returning) { return homePosition; } if (scattering) { return cornerPosition; } switch (ghostType) { default: return gridPosition; case PacManGhostType.Blinky: // Chase PacMan directly return PositionToGrid(pacMan.GetPosition()); case PacManGhostType.Pinky: // Try to get ahead of PacMan return GetTargetAheadOfPacMan(4); case PacManGhostType.Inky: // Try to attack from the opposite side of Blinky var blinkyPosition = PositionToGrid(blinky.GetPosition()); return ((GetTargetAheadOfPacMan(2) - blinkyPosition) * 2) + blinkyPosition; case PacManGhostType.Clyde: // Chase PacMan, but retreat to corner if PacMan gets to close var pacManPosition = PositionToGrid(pacMan.GetPosition()); if (Vector2.Distance(gridPosition, pacManPosition) < 8) { return cornerPosition; } return pacManPosition; } } private Vector2 GetTargetAheadOfPacMan(int tilesInFront) { var direction = pacMan.GetTargetDirection(); var result = PositionToGrid(pacMan.GetPosition()) + directionVectors[(int)direction] * tilesInFront; if (direction == Direction.Up) // Reproducing a bug in the original game { result.x -= tilesInFront; } return result; } bool CheckAndAllignToTargetX(Vector2 currentPosition, Vector2 nextPosition, Vector2 target) { return (currentPosition.x - target.x) * (nextPosition.x - target.x) <= 0.01; } bool CheckAndAllignToTargetY(Vector2 currentPosition, Vector2 nextPosition, Vector2 target) { return (currentPosition.y - target.y) * (nextPosition.y - target.y) <= 0.01; } void OffGridTargetReached() { switch (ghostState) { case PacManGhostState.Returning: offGrid = true; SetState(PacManGhostState.Entering); SetOffGridTarget(idlePosition2, false); break; case PacManGhostState.Entering: offGrid = true; SetState(PacManGhostState.Home); ghostManager.GhostReturned(); if (!target.Equals(idlePosition1)) { // This is idlePosition1 if PacManGhostStartState == TargetingIdlePosition1 SetOffGridTarget(idlePosition2, false); } TryToExit(); break; case PacManGhostState.Home: if (target.Equals(idlePosition1)) { SetOffGridTarget(idlePosition2, false); } else { SetOffGridTarget(idlePosition1, false); } break; case PacManGhostState.Exiting: offGrid = false; SetState(PacManGhostState.Normal); SetDirectionAndTargetDirection(Direction.Left); break; } } void SetOffGridTarget(Vector2 newTarget, bool startHorizontal) { if (startHorizontal) { SetDirectionAndTargetDirection(GetOffGridDirectionToTarget(GetPosition(), newTarget, Direction.Right)); } else { SetDirectionAndTargetDirection(GetOffGridDirectionToTarget(GetPosition(), newTarget, Direction.Down)); } // Debug.Log($"{gameObject} SetOffGridTarget with position {GetPosition()}, newTarget {newTarget}, startHorizontal {startHorizontal} resulted in direction {direction}"); target = newTarget; } public int PseudoRNG() { rngState ^= rngState << 13; rngState ^= rngState >> 17; rngState ^= rngState << 5; return Math.Abs(rngState); } private readonly Direction[] cardinalDirections = new Direction[] { Direction.Up, Direction.Left, Direction.Down, Direction.Right }; Direction GetGridDirectionToTargetGreedy(int availableDirections, Vector2 gridPosition, Vector2 targetGridPosition) { Direction bestDirection = Direction.Zero; float bestDistance = float.MaxValue; foreach (var direction in cardinalDirections) { if (((int)direction & availableDirections) == 0) { continue; } // Debug.Log("Evaluating direction " + direction); float distance = Vector2.Distance(gridPosition + directionVectors[(int)direction], targetGridPosition); if (distance < bestDistance) { bestDistance = distance; bestDirection = direction; } } //Debug.Log($"{gameObject.name} Closest next tile is in direction {bestDirection}"); return bestDirection; } private Direction GetOffGridDirectionToTarget(Vector2 position, Vector2 target, Direction direction) { if ((IsHorizontal(direction) && position.x != target.x) || position.y == target.y) { // Debug.Log($"{gameObject} getOffGridDirectionToTarget with position {position}, target {target} and direction {direction} gives movement on the X axis in direction {new Vector2(target.x-position.x, 0).normalized}"); return HorizontalToDirection(target.x - position.x); } else { // Debug.Log($"{gameObject} getOffGridDirectionToTarget with position {position}, target {target} and direction {direction} gives movement on the Y axis in direction {new Vector2(0, target.y-position.y).normalized}"); return VerticalToDirection(target.y - position.y); } } protected override void UpdateAnimator() { if (!gameObject.activeInHierarchy) return; if (frozenState == PacManGhostFrozenState.FrozenIfNotCaught) // Looks like a bug but matches the original game { return; } if (specialLook || targetDirection != Direction.Zero) { SetAnimatorDirection((int)targetDirection); } PacManGhostType ghostType = this.ghostType; if (isScared) { ghostType = whiteScared ? PacManGhostType.ScaredWhite : PacManGhostType.Scared; } else if (ghostState == PacManGhostState.Returning || ghostState == PacManGhostState.Entering) { ghostType = PacManGhostType.Caught; } else if (specialLook) { ghostType = PacManGhostType.Special; } SetAnimatorGhostType((int)ghostType); } // A Udon bug means converting an enum to a float causes an exception unless the conversion from int to float is in a separate method private void SetAnimatorDirection(int value) => animator.SetFloat(animatorKeyDirection, value); private void SetAnimatorGhostType(int value) => animator.SetFloat(animatorKeyGhostType, value); public void UpdateSpeed() { speed = ghostManager.GetTargetSpeed(this, ghostState, isScared, inTunnel); // Debug.Log($"Ghost with type {ghostType} updated speed to {speed}, ghostState: {ghostState}, isScared: {isScared}, inTunnel: {inTunnel}, elroyLevel: {ghostManager.elroyLevel}"); } public void ResetHousePelletCounter() { housePelletCounter = 0; } public void IncrementHousePelletCounter() { housePelletCounter++; TryToExit(); } void TryToExit() { if ((housePelletCounterActive && housePelletCounter >= housePelletCounterLimit) || startState == PacManGhostStartState.Outside) { Release(); } } internal bool HitPacMan() { if (ghostState != PacManGhostState.Normal) { return false; } if (isScared) { //Debug.Log($"{gameObject} was cought!"); ghostManager.GhostCaught(this); return true; } //Debug.Log($"{gameObject} cought PacMan!"); ghostManager.CapturedPacMan(); return true; } public void Caught(int scoreBonus) { isScared = false; // direction = direction * -1; target = homePosition; scoreBonusDisplay.Display(scoreBonus); SetVisibility(false); SetState(PacManGhostState.CaughtScore); } public void ReturnHome() { SetVisibility(true); scoreBonusDisplay.Hide(); SetState(PacManGhostState.Returning); } public void BecomeScared() { if (ghostState == PacManGhostState.Returning || ghostState == PacManGhostState.Entering) { return; } if (ghostState == PacManGhostState.Normal || ghostState == PacManGhostState.Home || ghostState == PacManGhostState.Exiting) { turnAroundSoon = true; } SetScared(true); } public void CalmDown() { if (isScared) { SetScared(false); } } public void Release() { // Debug.Log($"{gameObject} was released."); if (ghostState == PacManGhostState.Home) { // Debug.Log($"{gameObject} released."); SetOffGridTarget(homePosition, true); SetState(PacManGhostState.Exiting); } } public void SetState(PacManGhostState state) { ghostState = state; UpdateAnimator(); UpdateSpeed(); } private void SetScared(bool scared) { isScared = scared; SetWhite(false); UpdateSpeed(); } public void SetScattering(bool scattering, bool reverseDirection = true) { if (reverseDirection && this.scattering != scattering) { if (ghostState == PacManGhostState.Normal || ghostState == PacManGhostState.Home || ghostState == PacManGhostState.Exiting // This is afaik not normal PacMan behaviour, but is needed to accomidate slight timing differences during the demo || ghostState == PacManGhostState.Entering && ghostManager.gameController.GameState == PacManGameState.AttractModeDemo ) { turnAroundSoon = true; } } this.scattering = scattering; UpdateAnimator(); } public void SetFrozen(bool frozen, bool ignoreIfCaught = false, bool keepAnimating = false) { animator.speed = frozen && !keepAnimating ? 0 : 1; // This would cause issues if the returning sprite was animated, luckily it isn't :) if (frozen && !ignoreIfCaught) { frozenState = PacManGhostFrozenState.Frozen; return; } if (frozen && ignoreIfCaught) { frozenState = PacManGhostFrozenState.FrozenIfNotCaught; return; } var oldFrozenState = frozenState; frozenState = PacManGhostFrozenState.NotFrozen; if (oldFrozenState == PacManGhostFrozenState.FrozenIfNotCaught) // Catch animator up after not updating during FrozenIfNotCaught { UpdateAnimator(); } if (ghostState == PacManGhostState.CaughtScore) { ReturnHome(); // Return home when unfreezing after being caught } } public void SetHousePelletCounterActive(bool active) { housePelletCounterActive = active; } public void SetHousePelletCounterLimit(int limit) { housePelletCounterLimit = limit; } public void SetWhite(bool white) { whiteScared = white; UpdateAnimator(); } public void SetElroy(int elroyLevel) { // Debug.Log($"{gameObject} became Elroy level {elroyLevel}"); if (elroyLevel > 0) { SetScattering(false, reverseDirection: false); } UpdateSpeed(); } public void SetActive(bool active) { gameObject.SetActive(active); renderer.enabled = active; if (active) { UpdateAnimator(); } } public void SetKinematic(bool kinematic) { this.kinematic = kinematic; } public void SetPredefinedPath(Direction[] predefinedPath) { this.predefinedPath = predefinedPath; followingPredefinedPath = true; predefinedPathIndex = 0; } public void SetInTunnel(bool inTunnel) { this.inTunnel = inTunnel; UpdateSpeed(); } public void SetSpecialLook(bool enabled) { specialLook = enabled; UpdateAnimator(); } void SetVisibility(bool visible) { renderer.enabled = visible; } public PacManGhostState GetGhostState() { return ghostState; } public void SetSpeed(float speed) { this.speed = speed; UpdateAnimator(); } public override void CollectSyncedData(byte[] data, ref int index, NetworkEventType eventType) { if (eventType != NetworkEventType.FullSync) { return; } data.Append(target, ref index); data.Append(horizontalOnly, ref index); data.Append(inTunnel, ref index); data.Append(rngState, ref index); data.Append(turnAroundSoon, ref index); data.Append(speed, ref index); data.AppendAsByte((int)ghostState, ref index); data.Append(isScared, ref index); data.Append(whiteScared, ref index); data.Append(scattering, ref index); data.AppendAsByte((int)frozenState, ref index); data.Append(offGrid, ref index); data.AppendAsByte(housePelletCounter, ref index); data.Append(housePelletCounterActive, ref index); data.AppendAsByte(housePelletCounterLimit, ref index); base.CollectSyncedData(data, ref index, eventType); } public override bool WriteSyncedData(byte[] data, ref int index, NetworkEventType eventType) { if (eventType != NetworkEventType.FullSync) { return true; } target = data.ReadVector2(ref index); horizontalOnly = data.ReadBool(ref index); inTunnel = data.ReadBool(ref index); rngState = data.ReadInt(ref index); turnAroundSoon = data.ReadBool(ref index); speed = data.ReadFloat(ref index); ghostState = (PacManGhostState)data.ReadByte(ref index); isScared = data.ReadBool(ref index); whiteScared = data.ReadBool(ref index); scattering = data.ReadBool(ref index); frozenState = (PacManGhostFrozenState)data.ReadByte(ref index); offGrid = data.ReadBool(ref index); housePelletCounter = data.ReadByte(ref index); housePelletCounterActive = data.ReadBool(ref index); housePelletCounterLimit = data.ReadByte(ref index); UpdateSpeed(); return base.WriteSyncedData(data, ref index, eventType); } } }