using System; using System.Collections.Generic; using System.Linq; using Microsoft.Xna.Framework; using Microsoft.Xna.Framework.Graphics; using Microsoft.Xna.Framework.Input; namespace XNAPacMan { public enum GhostState { Home, Scatter, Attack, Blue, Dead } public enum Ghosts { Blinky, Pinky, Inky, Clyde } /// /// One of the ghosts that try to kill Pac Man. /// public class Ghost { /// /// Instantiates a ghost. /// /// A reference to the Game object, needed for access to services. /// A reference to the Pac Man, needed for AI. /// Which ghost, needed for appearance and behavior. public Ghost(Game game, Player player, Ghosts identity) { spriteBatch_ = (SpriteBatch)game.Services.GetService(typeof(SpriteBatch)); ghostBase1_ = game.Content.Load("sprites/GhostBase"); ghostBase2_ = game.Content.Load("sprites/GhostBase2"); ghostChased_ = game.Content.Load("sprites/GhostChased"); eyesBase_ = game.Content.Load("sprites/GhostEyes"); eyesCenter_ = game.Content.Load("sprites/GhostEyesCenter"); colorBase_ = Constants.colors(identity); identity_ = identity; previousNumCrumps_ = 0; Reset(true, player); wiggle_ = true; direction_ = new Direction(); lastJunction_ = new Point(); scatterTiles_ = Constants.scatterTiles(identity); } /// /// Put the ghosts back in their home, ready to begin a new attack /// /// True at level start, false otherwise /// The pac man. Pac Man is often respawned with the ghosts, so they need to know about the new Pac Man. public void Reset(bool newLevel, Player player) { State = GhostState.Home; // Ghosts start at home previousState_ = GhostState.Home; // Sounds are played when currentState != previousState. // So to get them playing at level start, we do this simple hack. updateCount_ = 0; initialJumps_ = Constants.InitialJumps(identity_, newLevel); position_ = Constants.startPosition(identity_); scheduleStateEval_ = true; lastJunction_ = new Point(); player_ = player; scatterModesLeft_ = 4; UpdateSpeed(); } /// /// When we need to lock the game, make sure the timers here are updated to reflect /// the "waste of time". /// public void LockTimer(GameTime gameTime) { timeInCurrentState += gameTime.ElapsedGameTime; } /// /// In case this ghost is Inky, he will need a reference to blinky in order /// to find his way around the maze. Otherwise, setting this has no effect. /// /// A reference to Blinky. public void SetBlinky(Ghost blinky) { blinky_ = blinky; } /// /// Call this every game update to get those ghosts moving around. /// /// Provides a snapshot of timing values. public void Update(GameTime gameTime) { if (scheduleStateEval_) { StateEval(); } // After the AI has had the occasion to run, update lastJuntion. if (position_.DeltaPixel == Point.Zero && IsAJunction(position_.Tile)) { lastJunction_ = position_.Tile; } Move(); } void PlaySound() { if (State == GhostState.Attack || State == GhostState.Scatter || State == GhostState.Home) { if (Grid.NumCrumps < 50) { GhostSoundsManager.playLoopAttackVeryFast(); } else if (Grid.NumCrumps < 120) { GhostSoundsManager.playLoopAttackFast(); } else { GhostSoundsManager.playLoopAttack(); } } else if (State == GhostState.Blue) { GhostSoundsManager.playLoopBlue(); } else if (State == GhostState.Dead) { GhostSoundsManager.playLoopDead(); } } void StateEval() { GhostState initialState = State; scheduleStateEval_ = false; switch (State) { case GhostState.Home: // Ghost exit the home state for the scatter state when they get to the row // above the home if (position_.Tile.Y == 11 && position_.DeltaPixel.Y == 0) { // Select inital direction based on scatter tiles if (Constants.scatterTiles(identity_)[0].X < 13) { direction_ = Direction.Left; } else { direction_ = Direction.Right; } if (scatterModesLeft_ > 0) { State = GhostState.Scatter; } else { State = GhostState.Attack; } return; } // Ghosts move up when they are aligned with the entrance else if (position_.Tile.X == 13 && position_.DeltaPixel.X == 8) { direction_ = Direction.Up; } // When on one side, move towards middle when on the bottom and time's up // If time's not up, keep bouncing up and down else if ((position_.DeltaPixel.Y == 8) && ((position_.Tile.X == 11 && position_.DeltaPixel.X == 8) || (position_.Tile.X == 15 && position_.DeltaPixel.X == 8))) { if (position_.Tile.Y == 14) { initialJumps_--; if (initialJumps_ == 0) { if (position_.Tile.X == 11) { direction_ = Direction.Right; } else { direction_ = Direction.Left; } } else { direction_ = Direction.Up; } } else if (position_.Tile.Y == 13) { direction_ = Direction.Down; } } break; case GhostState.Scatter: // Attempt to reverse direction upon entering this state if (previousState_ == GhostState.Attack) { scatterModesLeft_--; if (NextTile(OppositeDirection(direction_)).IsOpen) { direction_ = OppositeDirection(direction_); } } AIScatter(); int timeInScatterMode = scatterModesLeft_ <= 2 ? 5 : 7; if ((DateTime.Now - timeInCurrentState) > TimeSpan.FromSeconds(timeInScatterMode)) { State = GhostState.Attack; } break; case GhostState.Dead: // Attempt to reverse direction upon entering this state if (previousState_ != GhostState.Dead && previousState_ != GhostState.Blue) { if (NextTile(OppositeDirection(direction_)).IsOpen) { direction_ = OppositeDirection(direction_); } } else { AIDead(); } if (position_.DeltaPixel.X == 8 && position_.DeltaPixel.Y == 8) { if (position_.Tile.Y == 14) { State = GhostState.Home; } } break; case GhostState.Attack: // Attempt to reverse direction upon entering this state if (previousState_ != GhostState.Attack && previousState_ != GhostState.Blue) { if (NextTile(OppositeDirection(direction_)).IsOpen) { direction_ = OppositeDirection(direction_); } } else { AIAttack(); } if ((DateTime.Now - timeInCurrentState) > TimeSpan.FromSeconds(20)) { State = GhostState.Scatter; } break; case GhostState.Blue: // Attempt to reverse direction upon entering this state if (previousState_ != GhostState.Blue) { if (NextTile(OppositeDirection(direction_)).IsOpen) { direction_ = OppositeDirection(direction_); } } else { // TODO : make special blue AI AIAttack(); } // When blue time is over, revert to attack mode. if ((DateTime.Now - timeInCurrentState) > TimeSpan.FromSeconds(Constants.BlueTime())) { State = GhostState.Attack; } break; } // TODO : move all these magic numbers to the Constants class. // We select a new sound only upon some state change, or when // the number of crumps goes below certain thresholds. if ((initialState != previousState_) || (Grid.NumCrumps == 199 && previousNumCrumps_ == 200) || (Grid.NumCrumps == 19 && previousNumCrumps_ == 20)) { PlaySound(); } previousState_ = initialState; } void Move() { updateCount_++; updateCount_ %= updatesPerPixel_; if (updateCount_ == 0) { // There is no point running the full AI each update, especially considering we // want this code to run at 1000 updates per second. We run it each time it moves by a pixel. scheduleStateEval_ = true; // Now is a nice time to evaluate whether our current speed is correct. This variable // may change under a wide array of circumstances, so by putting it here we probably generate // too many checks but we ensure correct behavior. UpdateSpeed(); switch (direction_) { case Direction.Up: position_.DeltaPixel.Y--; if (position_.DeltaPixel.Y < 0) { position_.DeltaPixel.Y = 15; position_.Tile.Y--; } break; case Direction.Down: position_.DeltaPixel.Y++; if (position_.DeltaPixel.Y > 15) { position_.DeltaPixel.Y = 0; position_.Tile.Y++; } break; case Direction.Left: position_.DeltaPixel.X--; if (position_.DeltaPixel.X < 0) { position_.DeltaPixel.X = 15; position_.Tile.X--; // Special case : the tunnel if (position_.Tile.X < 0) { position_.Tile.X = Grid.Width - 1; } } break; case Direction.Right: position_.DeltaPixel.X++; if (position_.DeltaPixel.X > 15) { position_.DeltaPixel.X = 0; position_.Tile.X++; // Special case : the tunnel if (position_.Tile.X > Grid.Width - 1) { position_.Tile.X = 0; } } break; } } } void UpdateSpeed() { int baseSpeed = Constants.GhostSpeed(); if (State == GhostState.Home) { updatesPerPixel_ = baseSpeed * 2; } else if (State == GhostState.Blue) { updatesPerPixel_ = (int)(baseSpeed * 1.5); } else if (identity_ == Ghosts.Blinky && Grid.NumCrumps <= Constants.CruiseElroyTimer()) { updatesPerPixel_ = baseSpeed - 1; } // If in the tunnel, reduce speed else if (position_.Tile.Y == 14 && ((0 <= position_.Tile.X && position_.Tile.X <= 5) || (22 <= position_.Tile.X && position_.Tile.X <= 27))) { updatesPerPixel_ = baseSpeed + 5; } else { updatesPerPixel_ = baseSpeed; } } /// /// Guides the ghost towards his "favored" area of the board, as defined by scatterTiles_. /// void AIScatter() { // As with AIAttack(), all the method does is change direction if necessary, // which only happens when exactly at a junction if (position_.DeltaPixel != Point.Zero || !IsAJunction(position_.Tile)) { return; } // Scatter AI may be overriden by certain tiles if (AIOverride()) { return; } // If we are on one of our favored tiles, go on to the next int favoredTile = scatterTiles_.FindIndex(i => i == position_.Tile); int nextFavoredTile = (favoredTile + 1) % (scatterTiles_.Count); if (favoredTile != -1) { direction_ = FindDirection(scatterTiles_[nextFavoredTile]); } // If our last junction was a favored tile and this one isn't, ignore it else if (!scatterTiles_.Contains(lastJunction_)) { // Otherwise, find scatter tile closest to our position and head for it. List orderedTiles = scatterTiles_.ToList(); orderedTiles.Sort((a, b) => Vector2.Distance(new Vector2(position_.Tile.X, position_.Tile.Y), new Vector2(a.X, a.Y)). CompareTo( Vector2.Distance(new Vector2(position_.Tile.X, position_.Tile.Y), new Vector2(b.X, b.Y)))); direction_ = FindDirection(orderedTiles[0]); } } /// /// Guides the ghost to try to reach the player /// void AIAttack() { // All this method does is change direction if necessary. // There is only one case in which we may potentially change direction : // when the ghost is exactly at a junction. if (position_.DeltaPixel != Point.Zero || !IsAJunction(position_.Tile)) { return; } // Attack AI may be overriden by certain tiles if (AIOverride()) { return; } switch (identity_) { case Ghosts.Blinky: AttackAIBlinky(); break; case Ghosts.Pinky: AttackAIPinky(); break; case Ghosts.Inky: AttackAIInky(); break; case Ghosts.Clyde: AttackAIClyde(); break; default: throw new ArgumentException(); } } void AIDead() { // When aligned with the entrance, go down. When the ghost has fully entered the house, // stateEval will change state to Home. if (position_.Tile.Y == 11 && position_.Tile.X == 13 && position_.DeltaPixel.X == 8) { direction_ = Direction.Down; } // Otherwise, only change direction if the ghost is exactly on a tile else if (position_.DeltaPixel == Point.Zero) { // We direct the ghost towards one of the two squares above the home. When he reaches one, // he still has to move slightly right or left in order to align with the entrance. if (position_.Tile.X == 13 && position_.Tile.Y == 11) { direction_ = Direction.Right; } else if (position_.Tile.X == 14 && position_.Tile.Y == 11) { direction_ = Direction.Left; } // Otherwise, when at a junction, head for the square above home closest to us. else if (IsAJunction(position_.Tile)) { if (position_.Tile.X > 13) { direction_ = FindDirection(new Point(14, 11)); } else { direction_ = FindDirection(new Point(13, 11)); } } } } /// /// Blinky is extremely straightforward : head directly for the player /// void AttackAIBlinky() { direction_ = FindDirection(player_.Position.Tile); } /// /// Pinky tries to head for two tiles ahead of the player. /// void AttackAIPinky() { Tile nextTile = NextTile(player_.Direction, player_.Position); Tile nextNextTile = NextTile(player_.Direction, new Position(nextTile.ToPoint, Point.Zero)); direction_ = FindDirection(nextNextTile.ToPoint); } /// /// Inky is a bit more random. He will try to head for a square situated across /// the pac man from blinky's location. /// void AttackAIInky() { Tile nextTile = NextTile(player_.Direction, player_.Position); Tile nextNextTile = NextTile(player_.Direction, new Position(nextTile.ToPoint, Point.Zero)); Vector2 line = new Vector2(blinky_.Position.Tile.X - nextNextTile.ToPoint.X, blinky_.Position.Tile.Y - nextNextTile.ToPoint.Y); line *= 2; Point destination = new Point(position_.Tile.X + (int)line.X, position_.Tile.Y + (int)line.Y); // Prevent out-of-bounds exception destination.X = (int)MathHelper.Clamp(destination.X, 0, Grid.Width - 1); destination.Y = (int)MathHelper.Clamp(destination.Y, 0, Grid.Height - 1); direction_ = FindDirection(destination); } /// /// Clyde is the bizarre one. When within 8 tiles of the player, /// he will head for his favored corner (AIScatter). When farther away, /// he will near the player using Blinky's AI. /// void AttackAIClyde() { float distanceToPlayer = Vector2.Distance( new Vector2(player_.Position.Tile.X, player_.Position.Tile.Y), new Vector2(position_.Tile.X, position_.Tile.Y)); if (distanceToPlayer >= 8) { AttackAIBlinky(); } else { AIScatter(); } } // On certain tiles, we force all the ghosts in a particular direction. // This is to prevent them from bunching up too much, and give attentive // players some sure-fire ways to escape. bool AIOverride() { if (position_.Tile.Y == 11 && (position_.Tile.X == 12 || position_.Tile.X == 15)) { return (direction_ == Direction.Right || direction_ == Direction.Left); } else if (position_.Tile.Y == 20 && (position_.Tile.X == 9 || position_.Tile.X == 18)) { return (direction_ == Direction.Right || direction_ == Direction.Left); } else { return false; } } /// /// Returns the opposite of the specified direction /// /// direction /// opposite direction Direction OppositeDirection(Direction d) { switch (d) { case Direction.Up: return Direction.Down; case Direction.Down: return Direction.Up; case Direction.Left: return Direction.Right; case Direction.Right: return Direction.Left; default: throw new ArgumentException(); } } /// /// Returns in what direction we should go next in order to reach the destination. /// /// Where we want to go /// Where to go next Direction FindDirection(Point destination) { // We use a stupid but very efficient algorithm : go in the direction that // closes most distance between position and destination. This works well given // there are no dead ends and multiple paths leading to the destination. // However, we can never turn 180, and will rather choose a less-than-optimal path. int xDistance = destination.X - position_.Tile.X; int yDistance = destination.Y - position_.Tile.Y; var directions = new List(4); // Order directions by shortest path. Note: there is probably a better way to do this, probably if I hadn't used an // enum for directions in the first place. if (Math.Abs(xDistance) > Math.Abs(yDistance)) { if (xDistance > 0) { if (yDistance < 0) { // Direction is Up-Right, favor Right directions = new List { Direction.Right, Direction.Up, Direction.Down, Direction.Left }; } else { // Direction is Down-Right, favor Right directions = new List { Direction.Right, Direction.Down, Direction.Up, Direction.Left }; } } else { if (yDistance < 0) { // Direction is Up-Left, favor Left directions = new List { Direction.Left, Direction.Up, Direction.Down, Direction.Right }; } else { // Direction is Down-Left, favor Left directions = new List { Direction.Left, Direction.Down, Direction.Up, Direction.Right }; } } } else { if (xDistance > 0) { if (yDistance < 0) { // Direction is Up-Right, favor Up directions = new List { Direction.Up, Direction.Right, Direction.Left, Direction.Down }; } else { // Direction is Down-Right, favor Down directions = new List { Direction.Down, Direction.Right, Direction.Left, Direction.Up }; } } else { if (yDistance < 0) { // Direction is Up-Left, favor Up directions = new List { Direction.Up, Direction.Left, Direction.Right, Direction.Down }; } else { // Direction is Down-Left, favor Down directions = new List { Direction.Down, Direction.Left, Direction.Right, Direction.Up }; } } } // Select first item in the list to meet two essential conditions : the path ahead is open, // and it's not the opposite direction. If we can't meet both conditions, just return the first // direction that leads to an open square. int index = directions.FindIndex(i => i != OppositeDirection(direction_) && NextTile(i).IsOpen); if (index != -1) { return directions[index]; } else { // Put a breakpoint here, this should never happen. return directions.Find(i => NextTile(i).IsOpen); } } /// /// Retrieves the next tile in the specified direction from the ghost's position. /// /// Direction in which to look /// The tile Tile NextTile(Direction d) { return NextTile(d, position_); } /// /// Retrieves the next tile in the specified direction from the specified position. /// /// Direction in which to look /// Position from which to look /// The tile public static Tile NextTile(Direction d, Position p) { switch (d) { case Direction.Up: if (p.Tile.Y - 1 < 0) { return Grid.TileGrid[p.Tile.X, p.Tile.Y]; } else { return Grid.TileGrid[p.Tile.X, p.Tile.Y - 1]; } case Direction.Down: if (p.Tile.Y + 1 >= Grid.Height) { return Grid.TileGrid[p.Tile.X, p.Tile.Y]; } else { return Grid.TileGrid[p.Tile.X, p.Tile.Y + 1]; } case Direction.Left: // Special case : the tunnel if (p.Tile.X == 0) { return Grid.TileGrid[Grid.Width - 1, p.Tile.Y]; } else { return Grid.TileGrid[p.Tile.X - 1, p.Tile.Y]; } case Direction.Right: // Special case : the tunnel if (p.Tile.X + 1 >= Grid.Width) { return Grid.TileGrid[0, p.Tile.Y]; } else { return Grid.TileGrid[p.Tile.X + 1, p.Tile.Y]; } default: throw new ArgumentException(); } } /// /// Returns whether the specified tile is a junction. /// /// Tile to check /// whether the specified tile is a junction bool IsAJunction(Point tile) { if (NextTile(direction_).Type == TileTypes.Open) { // If the path ahead is open, we're at a junction if it's also open // to one of our sides if (direction_ == Direction.Up || direction_ == Direction.Down) { return ((NextTile(Direction.Left).IsOpen) || (NextTile(Direction.Right).IsOpen)); } else { return ((NextTile(Direction.Up).IsOpen) || (NextTile(Direction.Down).IsOpen)); } } // If the path ahead is blocked, then we're definitely at a junction, because there are no dead-ends else { return true; } } /// /// Assumes spritebatch.begin() was called /// /// Provides a snapshot of timing values. public void Draw(GameTime gameTime, Vector2 boardPosition) { // Ghosts have a two-frame animation; we use a bool to toggle between // the two. We divide DateTime.Now.Milliseconds by the period. if (((DateTime.Now.Millisecond / 125) % 2) == 0 ^ wiggle_) { wiggle_ = !wiggle_; } Vector2 position; position.X = boardPosition.X + (position_.Tile.X * 16) + (position_.DeltaPixel.X) - 6; position.Y = boardPosition.Y + (position_.Tile.Y * 16) + (position_.DeltaPixel.Y) - 6; Vector2 eyesBasePosition; eyesBasePosition.X = position.X + 4; eyesBasePosition.Y = position.Y + 6; Vector2 eyesCenterPosition = new Vector2(); switch (direction_) { case Direction.Up: eyesBasePosition.Y -= 2; eyesCenterPosition.X = eyesBasePosition.X + 2; eyesCenterPosition.Y = eyesBasePosition.Y; break; case Direction.Down: eyesBasePosition.Y += 2; eyesCenterPosition.X = eyesBasePosition.X + 2; eyesCenterPosition.Y = eyesBasePosition.Y + 6; break; case Direction.Left: eyesBasePosition.X -= 2; eyesCenterPosition.X = eyesBasePosition.X; eyesCenterPosition.Y = eyesBasePosition.Y + 3; break; case Direction.Right: eyesBasePosition.X += 2; eyesCenterPosition.X = eyesBasePosition.X + 4; eyesCenterPosition.Y = eyesBasePosition.Y + 3; break; } if (State == GhostState.Blue) { if (((DateTime.Now - timeInCurrentState).Seconds < 0.5 * Constants.BlueTime())) { RenderSprite(wiggle_ ? ghostBase1_ : ghostBase2_, null, boardPosition, position, Color.Blue); RenderSprite(ghostChased_, null, boardPosition, position, Color.White); } else { bool flash = (DateTime.Now.Second + DateTime.Now.Millisecond / 200) % 2 == 0; RenderSprite(wiggle_ ? ghostBase1_ : ghostBase2_, null, boardPosition, position, flash ? Color.Blue : Color.White); RenderSprite(ghostChased_, null, boardPosition, position, flash ? Color.White : Color.Blue); } } else if (State == GhostState.Dead) { RenderSprite(eyesBase_, null, boardPosition, eyesBasePosition, Color.White); RenderSprite(eyesCenter_, null, boardPosition, eyesCenterPosition, Color.White); } else { RenderSprite(wiggle_ ? ghostBase1_ : ghostBase2_, null, boardPosition, position, colorBase_); RenderSprite(eyesBase_, null, boardPosition, eyesBasePosition, Color.White); RenderSprite(eyesCenter_, null, boardPosition, eyesCenterPosition, Color.White); } } /// /// Allows rendering across the tunnel, which is tricky. /// /// Source texture /// Portion of the source to render. Pass null for rendering the whole texture. /// Top-left pixel of the board in the screen /// Where to render the texture. void RenderSprite(Texture2D spriteSheet, Rectangle? rectangle, Vector2 boardPosition, Vector2 position, Color color) { Rectangle rect = rectangle == null ? new Rectangle(0, 0, spriteSheet.Width, spriteSheet.Height) : rectangle.Value; int textureWidth = rectangle == null ? spriteSheet.Width : rectangle.Value.Width; int textureHeight = rectangle == null ? spriteSheet.Height : rectangle.Value.Height; // What happens when we are crossing to the other end by the tunnel? // We detect if part of the sprite is rendered outside of the board. // First, to the left. if (position.X < boardPosition.X) { int deltaPixel = (int)(boardPosition.X - position.X); // Number of pixels off the board var leftPortion = new Rectangle(rect.X + deltaPixel, rect.Y, textureWidth - deltaPixel, textureHeight); var leftPortionPosition = new Vector2(boardPosition.X, position.Y); var rightPortion = new Rectangle(rect.X, rect.Y, deltaPixel, textureHeight); var rightPortionPosition = new Vector2(boardPosition.X + (16 * 28) - deltaPixel, position.Y); spriteBatch_.Draw(spriteSheet, leftPortionPosition, leftPortion, color); spriteBatch_.Draw(spriteSheet, rightPortionPosition, rightPortion, color); } // Next, to the right else if (position.X > (boardPosition.X + (16 * 28) - textureWidth)) { int deltaPixel = (int)((position.X + textureWidth) - (boardPosition.X + (16 * 28))); // Number of pixels off the board var leftPortion = new Rectangle(rect.X + textureWidth - deltaPixel, rect.Y, deltaPixel, textureHeight); var leftPortionPosition = new Vector2(boardPosition.X, position.Y); var rightPortion = new Rectangle(rect.X, rect.Y, textureWidth - deltaPixel, textureHeight); var rightPortionPosition = new Vector2(position.X, position.Y); spriteBatch_.Draw(spriteSheet, leftPortionPosition, leftPortion, color); spriteBatch_.Draw(spriteSheet, rightPortionPosition, rightPortion, color); } // Draw normally - in one piece else { spriteBatch_.Draw(spriteSheet, position, rect, color); } } // DRAWING SpriteBatch spriteBatch_; Texture2D ghostBase1_; Texture2D ghostBase2_; Texture2D ghostChased_; Texture2D eyesBase_; Texture2D eyesCenter_; Color colorBase_; bool wiggle_; // LOGIC Ghost blinky_; // Only Inky needs this for his AI Ghosts identity_; Direction direction_; Position position_; GhostState state_; GhostState previousState_; List scatterTiles_; Point lastJunction_; DateTime timeInCurrentState; Player player_; int updatesPerPixel_; bool scheduleStateEval_; int scatterModesLeft_; int initialJumps_; int previousNumCrumps_; int updateCount_; public GhostState State { get { return state_; } set { state_ = value; timeInCurrentState = DateTime.Now; } } public Position Position { get { return position_; } } public Ghosts Identity { get { return identity_; } } } }