Unity3D仿星露谷物语开发63之NPC移动
1、目标
实现NPC移动到目标点。
2、NPC在路径上移动架构图
流程拆解:
- 触发寻路请求:由AStartTest调用NPCPath.BuildPath,启动寻路流程
- 调度管理模块:NPCPath进一步调用NPCManager.BuildPath,并把npcMovementStepStack(移动步骤的容器,后续存寻路结果)传递过去,让NPCManager接手统筹
- 执行寻路算法:NPCManager调用AStar.BuildPath,带着npcMovementStepStack让A*算法工作,A*会计算出从起点到目标点的移动路径,把每一步的网格位置压入npcMovementStepStack
- 更新路径数据 :寻路完成后,npcMovementStepStack带着计算好的路径,回传给NPC里的NPCPath,此时路径数据就位
- 驱动NPC移动:NPCMovement里的Fixed Update被调用,从npcMovementStepStack里弹出下一步位置,驱动NPC移动到对应网格坐标,循环执行直到栈空
3、修改Enums.cs脚本
添加一个枚举类:
public enum Weather
{dry,raining,snowing,none,count
}
不同的天气会对应NPC不同的行为。
4、修改Settings.cs脚本
5、修改TimeManager.cs脚本
添加函数:
public TimeSpan GetGameTime()
{TimeSpan gameTime = new TimeSpan(gameHour, gameMinute, gameSecond);return gameTime;
}
6、修改AStar.cs脚本
添加一行代码:
7、创建NPC.cs脚本
在Assets -> Scripts -> NPC目录下创建NPC.cs脚本
暂时是一个空类:
using System.Collections;
using System.Collections.Generic;
using UnityEngine;public class NPC : MonoBehaviour
{}
然后把该组件加到NPC预制体上。
8、创建NPCManager.cs脚本
在Assets -> Scripts -> NPC目录下创建NPCManager.cs脚本
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;[RequireComponent(typeof(AStar))]
public class NPCManager : SingletonMonobehaviour<NPCManager>
{[HideInInspector]public NPC[] npcArray;private AStar aStar;protected override void Awake(){base.Awake();aStar = GetComponent<AStar>();// Get NPC gameobjects in scenenpcArray = FindObjectsOfType<NPC>();}private void OnEnable(){EventHandler.AfterSceneLoadEvent += AfterSceneLoad;}private void OnDisable(){EventHandler.AfterSceneLoadEvent -= AfterSceneLoad;}private void AfterSceneLoad(){SetNPCsActiveStatus();}private void SetNPCsActiveStatus(){foreach(NPC npc in npcArray){NPCMovement nPCMovement = npc.GetComponent<NPCMovement>();if(nPCMovement.npcCurrentScene.ToString() == SceneManager.GetActiveScene().name){nPCMovement.SetNPCActiveInScene();}else{nPCMovement.SetNPCInactiveInScene();}}}public bool BuildPath(SceneName sceneName, Vector2Int startGridPosition, Vector2Int endGridPosition, Stack<NPCMovementStep> npcMovementStepStack){if(aStar.BuildPath(sceneName, startGridPosition, endGridPosition, npcMovementStepStack)){return true;}else{return false;}}}
给NPCManager对象添加NPCManager组件。
9、创建NPCScheduleEvent.cs脚本
在Assets -> Scripts -> NPC目录下创建NPCScheduleEvent.cs脚本
该类会向NPC路径传递有关NPC必须做什么的信息。
这些NPC调度事件会由NPC调度程序创建。当某些事件发生时,其中一个NPC计划的事件会被创建指导NPC运动。
NPCScheduleEvent中的hour、minute是生效的事件。
priority:本来有几件事情时间一样,通过该字段区分它们的优先级。
using UnityEngine;[System.Serializable]
public class NPCScheduleEvent
{public int hour;public int minute;public int priority;public int day;public Weather weather;public Season season;public SceneName toSceneName;public GridCoordinate toGridCoordinate;public Direction npcFacingDirectionAtDestination = Direction.none;public AnimationClip animationAtDestination;public int Time{get{return (hour * 100) * minute;}}public NPCScheduleEvent(int hour, int minute, int priority, int day, Weather weather, Season season, SceneName toSceneName, GridCoordinate toGridCoordinate, AnimationClip animationAtDestination) {this.hour = hour;this.minute = minute;this.priority = priority;this.day = day;this.weather = weather;this.season = season;this.toSceneName = toSceneName;this.toGridCoordinate = toGridCoordinate;this.animationAtDestination = animationAtDestination;}public NPCScheduleEvent(){}public override string ToString(){return $"Time: {Time}, Priority: {priority}, Day: {day} Weather: {weather}, Season: {season}";}
}
10、创建NPCPath.cs脚本
在Assets -> Scripts -> NPC目录下创建NPCPath.cs脚本。
建立一条路径,然后更新路径上每个点的时间。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;[RequireComponent(typeof(NPCMovement))]
public class NPCPath : MonoBehaviour
{public Stack<NPCMovementStep> npcMovementStepStack;private NPCMovement npcMovement;private void Awake(){npcMovement = GetComponent<NPCMovement>();npcMovementStepStack = new Stack<NPCMovementStep>();}public void ClearPath(){npcMovementStepStack.Clear();}public void BuildPath(NPCScheduleEvent npcScheduleEvent){ClearPath();// If schedule event is for the same scene as the current NPC sceneif(npcScheduleEvent.toSceneName == npcMovement.npcCurrentScene){Vector2Int npcCurrentGridPosition = (Vector2Int)npcMovement.npcCurrentGridPosition;Vector2Int npcTargetGridPosition = (Vector2Int)npcScheduleEvent.toGridCoordinate;// Build path and add movement steps to movement step stackNPCManager.Instance.BuildPath(npcScheduleEvent.toSceneName, npcCurrentGridPosition, npcTargetGridPosition, npcMovementStepStack);// if stack count > 1, update times and then pop off 1st item which is the starting positionif(npcMovementStepStack.Count > 1){UpdateTimesOnPath();npcMovementStepStack.Pop(); // discard starting step// Set schedule event details in NPC movementnpcMovement.SetScheduleEventDetails(npcScheduleEvent);}}}/// <summary>/// Update the path movement steps with expected gametime/// </summary>public void UpdateTimesOnPath(){// Get current game timeTimeSpan currentGameTime = TimeManager.Instance.GetGameTime();NPCMovementStep previousNPCMovementStep = null;foreach(NPCMovementStep npcMovementStep in npcMovementStepStack){if(previousNPCMovementStep == null){previousNPCMovementStep = npcMovementStep;}npcMovementStep.hour = currentGameTime.Hours;npcMovementStep.minute = currentGameTime.Minutes;npcMovementStep.second = currentGameTime.Seconds;TimeSpan movementTimeStep;// if diagonalif(MovementIsDiagonal(npcMovementStep, previousNPCMovementStep)){movementTimeStep = new TimeSpan(0, 0, (int)(Settings.gridCellDiagonalSize / Settings.secondsPerGameSecond / npcMovement.npcNormalSpeed));}else{movementTimeStep = new TimeSpan(0, 0, (int)(Settings.gridCellSize / Settings.secondsPerGameSecond / npcMovement.npcNormalSpeed));}currentGameTime = currentGameTime.Add(movementTimeStep);previousNPCMovementStep = npcMovementStep;}}private bool MovementIsDiagonal(NPCMovementStep npcMovementStep, NPCMovementStep previoudNPCMovementStep){if((npcMovementStep.gridCoordinate.x != previoudNPCMovementStep.gridCoordinate.x)&& (npcMovementStep.gridCoordinate.y != previoudNPCMovementStep.gridCoordinate.y)){return true;}else{return false;}}}
11、创建NPCMovement.cs脚本
在Assets -> Scripts -> NPC目录下创建NPCMovement.cs脚本。
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.SceneManagement;[RequireComponent(typeof(Rigidbody2D))]
[RequireComponent(typeof(Animator))]
[RequireComponent(typeof(NPCPath))]
[RequireComponent(typeof(SpriteRenderer))]
[RequireComponent(typeof(BoxCollider2D))]
public class NPCMovement : MonoBehaviour
{[HideInInspector] public SceneName npcCurrentScene;[HideInInspector] public SceneName npcTargetScene;[HideInInspector] public Vector3Int npcCurrentGridPosition;[HideInInspector] public Vector3Int npcTargetGridPosition;[HideInInspector] public Vector3 npcTargetWorldPosition;[HideInInspector] public Direction npcFacingDirectionAtDestination;private SceneName npcPreviousMovementStepScene;private Vector3Int npcNextGridPosition;private Vector3 npcNextWorldPosition;[Header("NPC Movement")]public float npcNormalSpeed = 2f;[SerializeField] private float npcMinSpeed = 1f;[SerializeField] private float npcMaxSpeed = 3f;private bool npcIsMoving = false;[HideInInspector] public AnimationClip npcTargetAniamtionClip; // 到达终点时的动画[Header("NPC Animation")][SerializeField] private AnimationClip blankAnimation = null;private Grid grid;private Rigidbody2D rigidbody2D;private BoxCollider2D boxCollider2D;private WaitForFixedUpdate waitForFixedUpdate;private Animator animator;private AnimatorOverrideController animatorOverrideController;private int lastMoveAnimationParameter;private NPCPath npcPath;private bool npcInitialised = false;private SpriteRenderer spriteRenderer;[HideInInspector] public bool npcActiveInScene = false;private bool sceneLoaded = false;private Coroutine moveToGridPositionRoutine;private void OnEnable(){EventHandler.AfterSceneLoadEvent += AfterSceneLoad;EventHandler.BeforeSceneUnloadEvent += BeforeSceneUnloaded; }private void OnDisable(){EventHandler.AfterSceneLoadEvent -= AfterSceneLoad;EventHandler.BeforeSceneUnloadEvent -= BeforeSceneUnloaded;}private void Awake(){rigidbody2D = GetComponent<Rigidbody2D>();boxCollider2D = GetComponent<BoxCollider2D>();animator = GetComponent<Animator>();npcPath = GetComponent<NPCPath>();spriteRenderer = GetComponent<SpriteRenderer>();animatorOverrideController = new AnimatorOverrideController(animator.runtimeAnimatorController);animator.runtimeAnimatorController = animatorOverrideController;// Initialise target world position, target grid postiion & target scene to currentnpcTargetScene = npcCurrentScene;npcTargetGridPosition = npcCurrentGridPosition;npcTargetWorldPosition = transform.position;}private void Start(){waitForFixedUpdate = new WaitForFixedUpdate();SetIdleAnimation();}private void SetIdleAnimation(){animator.SetBool(Settings.idleDown, true);}private void FixedUpdate(){if (sceneLoaded){if(npcIsMoving == false){// set npc current and next grid position - to take into account the npc might be animatingnpcCurrentGridPosition = GetGridPosition(transform.position);npcNextGridPosition = npcCurrentGridPosition;if(npcPath.npcMovementStepStack.Count > 0){NPCMovementStep npcMovementStep = npcPath.npcMovementStepStack.Peek();npcCurrentScene = npcMovementStep.sceneName;// If NPC is in current scene then set NPC to active to make visible, pop the movement step off the stack // and then call method to move NPCif(npcCurrentScene.ToString() == SceneManager.GetActiveScene().name){SetNPCActiveInScene();npcMovementStep = npcPath.npcMovementStepStack.Pop();npcNextGridPosition = (Vector3Int)npcMovementStep.gridCoordinate;TimeSpan npcMovementStepTime = new TimeSpan(npcMovementStep.hour, npcMovementStep.minute, npcMovementStep.second);MoveToGridPosition(npcNextGridPosition, npcMovementStepTime, TimeManager.Instance.GetGameTime());}}// else if no more NPC movement stepselse{ResetMoveAnimation();SetNPCFacingDirection();SetNPCEventAnimation();}}}}private void SetNPCEventAnimation(){if(npcTargetAniamtionClip != null){ResetIdleAnimation();animatorOverrideController[blankAnimation] = npcTargetAniamtionClip;animator.SetBool(Settings.eventAnimation, true);}else{animatorOverrideController[blankAnimation] = blankAnimation;animator.SetBool(Settings.eventAnimation, false);}}private void SetNPCFacingDirection(){ResetIdleAnimation();switch (npcFacingDirectionAtDestination){case Direction.up:animator.SetBool(Settings.idleUp, true);break;case Direction.down:animator.SetBool(Settings.idleDown, true);break;case Direction.left:animator.SetBool(Settings.idleLeft, true);break;case Direction.right:animator.SetBool(Settings.idleRight, true);break;case Direction.none:break;default:break;}}private void ResetMoveAnimation(){animator.SetBool(Settings.walkRight, false);animator.SetBool(Settings.walkLeft, false);animator.SetBool(Settings.walkUp, false);animator.SetBool(Settings.walkDown, false);}private void ResetIdleAnimation(){animator.SetBool(Settings.idleRight, false);animator.SetBool(Settings.idleLeft, false);animator.SetBool(Settings.idleUp, false);animator.SetBool(Settings.idleDown, false);}private void MoveToGridPosition(Vector3Int gridPosition, TimeSpan npcMovementStepTime, TimeSpan gameTime){moveToGridPositionRoutine = StartCoroutine(MoveToGridPositionRoutine(gridPosition, npcMovementStepTime, gameTime));}private IEnumerator MoveToGridPositionRoutine(Vector3Int gridPosition, TimeSpan npcMovementStepTime, TimeSpan gameTime){npcIsMoving = true;SetMoveAnimation(gridPosition);npcNextWorldPosition = GetWorldPosition(gridPosition);// If movement step time is in the future, otherwise skip and move NPC immediately to positionif(npcMovementStepTime > gameTime){// calculate time difference in secondsfloat timeToMove = (float)(npcMovementStepTime.TotalSeconds - gameTime.TotalSeconds);// Calculate speedfloat npcCalculatedSpeed = Vector3.Distance(transform.position, npcNextWorldPosition) / timeToMove / Settings.secondsPerGameSecond;// if speed is at least npc min speed and lesss npc max speed then process, otherwise skip and move NPC immediately to positionif(npcCalculatedSpeed >= npcMinSpeed && npcCalculatedSpeed <= npcMaxSpeed){while(Vector3.Distance(transform.position, npcNextWorldPosition) > Settings.pixelSize){Vector3 unitVector = Vector3.Normalize(npcNextWorldPosition - transform.position);Vector2 move = new Vector2(unitVector.x * npcCalculatedSpeed * Time.fixedDeltaTime, unitVector.y * npcCalculatedSpeed * Time.fixedDeltaTime);rigidbody2D.MovePosition(rigidbody2D.position + move);yield return waitForFixedUpdate;}}Debug.Log("here!");}rigidbody2D.position = npcNextWorldPosition;npcCurrentGridPosition = gridPosition;npcNextGridPosition = npcCurrentGridPosition;npcIsMoving = false;}private void SetMoveAnimation(Vector3Int gridPosition){// Reset idle animationResetIdleAnimation();// Reset move animationResetMoveAnimation();// get world positionVector3 toWorldPosition = GetWorldPosition(gridPosition);// get vectorVector3 directionVector = toWorldPosition - transform.position;if(Mathf.Abs(directionVector.x) >= Mathf.Abs(directionVector.y)){// Use left/right animationif(directionVector.x > 0){animator.SetBool(Settings.walkRight, true);}else{animator.SetBool(Settings.walkLeft, true);}}else{// Use up/down animationif(directionVector.y > 0){animator.SetBool(Settings.walkUp, true);}else{animator.SetBool(Settings.walkDown, true);}}}private Vector3Int GetGridPosition(Vector3 worldPosition){if(grid != null){return grid.WorldToCell(worldPosition);}else{return Vector3Int.zero;}}public void SetNPCActiveInScene(){spriteRenderer.enabled = true;boxCollider2D.enabled = true;npcActiveInScene = true;}private void AfterSceneLoad(){grid = GameObject.FindObjectOfType<Grid>();if (!npcInitialised){InitialisedNPC();npcInitialised = true;}sceneLoaded = true;}private void InitialisedNPC(){// Active in sceneif(npcCurrentScene.ToString() == SceneManager.GetActiveScene().name){SetNPCActiveInScene();}else{SetNPCInactiveInScene();}// Get NPC Current Grid PositionnpcCurrentGridPosition = GetGridPosition(transform.position);// Set next grid position and target grid position to current grid positionnpcNextGridPosition = npcCurrentGridPosition;npcTargetGridPosition = npcCurrentGridPosition;npcTargetWorldPosition = GetWorldPosition(npcTargetGridPosition);// Get NPC WorldPositionnpcNextWorldPosition = GetWorldPosition(npcCurrentGridPosition);}private void BeforeSceneUnloaded(){sceneLoaded = false;}public void SetScheduleEventDetails(NPCScheduleEvent npcScheduleEvent){npcTargetScene = npcScheduleEvent.toSceneName;npcTargetGridPosition = (Vector3Int)npcScheduleEvent.toGridCoordinate;npcTargetWorldPosition = GetWorldPosition(npcTargetGridPosition);npcFacingDirectionAtDestination = npcScheduleEvent.npcFacingDirectionAtDestination;npcTargetAniamtionClip = npcScheduleEvent.animationAtDestination;ClearNPCEventAnimation();}public void ClearNPCEventAnimation(){animatorOverrideController[blankAnimation] = blankAnimation;animator.SetBool(Settings.eventAnimation, false);// Clear any rotation on npctransform.rotation = Quaternion.identity;}public void SetNPCInactiveInScene(){spriteRenderer.enabled = false;boxCollider2D.enabled = false;npcActiveInScene = false;}/// <summary>/// returns the world position (centre of grid square) from gridPosition/// </summary>/// <param name="gridPosition"></param>/// <returns></returns>public Vector3 GetWorldPosition(Vector3Int gridPosition){Vector3 worldPosition = grid.CellToWorld(gridPosition);// Get centre of grid squarereturn new Vector3(worldPosition.x + Settings.gridCellSize / 2f, worldPosition.y + Settings.gridCellSize / 2f, worldPosition.z);}}
12、配置对象
(1)NPC_Butch配置组件
点击Assets -> Prefabs -> NPC,然后点击其下的NPC,添加NPC Movement组件,同时会自动添加NPCPath组件。配置其参数如下:
此时看NPC_Butch对象,也添加了NPCMovement和NPCPath组件。
(2)删除Grid对象
删除Hierarchy下的Grid对象。
(3)修改AStarTest.cs脚本
重写这块代码如下:
using UnityEngine;public class AStarTest : MonoBehaviour
{[SerializeField] private NPCPath npcPath = null;[SerializeField] private bool moveNPC = false;[SerializeField] private Vector2Int finishPosition;[SerializeField] private AnimationClip idleDownAnimationClip = null;[SerializeField] private AnimationClip eventAnimationClip = null;private NPCMovement npcMovement;private void Start(){npcMovement = npcPath.GetComponent<NPCMovement>();npcMovement.npcFacingDirectionAtDestination = Direction.down;npcMovement.npcTargetAniamtionClip = idleDownAnimationClip;}private void Update(){if (moveNPC){moveNPC = false;NPCScheduleEvent npcScheduleEvent = new NPCScheduleEvent(0, 0, 0, 0, Weather.none, Season.none, SceneName.Scene1_Farm, new GridCoordinate(finishPosition.x, finishPosition.y), eventAnimationClip);npcPath.BuildPath(npcScheduleEvent);}}
}
(4)配置NPCManager对象
13、VIP代码流程解读(走读代码先看这里)
- NPCScheduleEvent类定义了NPC要做的事项,大致上是在哪个场景下(场景信息)什么时间(时间信息)去哪里(位置信息)做什么事情(动画信息)。
- 在AStarTest中让npcPath.BuildPath处理NPCScheduleEvent中的路径信息。
3. NPCPath主要是计算出路径(多个点)以及路径上每个点的时间,即什么时间需要到达点。路径是通过AStar算法计算出来的,该信息更新到npcMovementStepStack中。
计算时间的方式:获取当前的时间,然后每获取路径上一个点,该点的时间就往后延一点,这样就得到了路径上每个点需要到达的时间。该信息更新到npcMovementStep中。
4. NPCMovement:通过协程的方式实现NPC的实际移动。在FixedUpdate中不断获取npcMovementStepStack的元素,对于每一个元素,如果当前时间超过了元素中的时间,则通过rigidbody2D.MovePosition逐步移动到元素中的位置。
14、运行游戏
(1)只行走
NPC当前位于(-6,0)
勾选Move NPC:
(2)行走完抽烟
因为录像的原因,导致Unity运行跳帧特别明显。