核心系统又可以分为引擎级核心系统和玩法设计核心系统两大类。下面按这两层讲清楚关键构成与作用。

引擎级核心系统

负责 “游戏能跑起来、画面 / 物理 / 声音正常”,是所有玩法的基础。

渲染系统(Rendering)

  • 负责 3D/2D 画面生成:模型、贴图、光照、阴影、粒子、后处理( bloom / 抗锯齿) - 决定画面风格、帧率、画质档位

物理系统(Physics)

  • 模拟真实世界规则:重力、碰撞、刚体、关节、布料、流体
  • 典型组件:PhysX、Havok、Bullet;决定 “角色能不能站稳、子弹会不会穿墙”

(详情参见Unity之物理系统

动画系统(Animation)

  • 骨骼动画、状态机、混合树、IK(反向运动)、动画事件
  • 让角色动作自然:走路、攻击、受击、表情

(详情参见Unity核心-动画基础

音频系统(Audio)

  • 3D 声音定位、衰减、混响、音效 / 音乐播放、优先级管理
  • 决定打击感、氛围、空间感

(详情参见Unity之音频系统

AI 系统(Artificial Intelligence)

  • NPC / 怪物行为:寻路、状态机、感知、战术决策、群体行为
  • 高级如《潜行者 2》A-life:NPC 自主生存、交战、迁徙

资源管理系统(Resource)

  • 加载 / 卸载、内存池、对象池、打包压缩、热更新
  • 决定游戏流畅度与加载速度

(详情参见Unity之资源管理系统

网络系统(Network)

  • 客户端 - 服务器架构、同步 / 异步、帧同步 / 状态同步、防作弊
  • 支撑多人联机、PVP、社交

UI / 输入系统

  • 界面布局、事件响应、手柄 / 键盘 / 触屏适配、焦点管理
  • 决定操作手感与信息传递效率

(详情参见UGUI学习笔记Unity进阶-InputSystem

玩法级核心系统

决定 “玩家玩什么、怎么玩、为什么玩”,直接塑造体验循环。

我的个人建议是通过引擎级核心系统来判断知识点掌握情况并加以学习,在实际游戏开发过程中,玩法级核心系统可能涉及的会更多。

角色控制系统

角色控制系统包含控制玩家的移动、动画、拾取物品。

角色的移动

控制玩家的移动有两种方式,其一是旧输入系统,另一种是Unity InputSystem新输入系统。

  • 旧输入系统

旧输入系统是依靠按键驱动的,代码直接写死物理按键:

1
2
3
4
5
6
7
void Update()
{
if(Input.GetKeyDown(KeyCode.Space) && IsGrounded())
{
Jump();
}
}

Jump() 跳跃方法:

1
2
3
4
5
6
7
8
9
public void Jump()
{
if(!_isGrounded) return;
if(_isJumping) return;

_isJumping = true;

_rb.velocity = new Vector2(_jumpDir * _jumpHorizontalSpeed, _jumpForce);
}
  • 新输入系统

新输入系统中常用Invoke Unity Events来控制玩家移动,直接在Inspector面板选择就可以使用:

1
2
3
4
5
6
7
8
9
public void Jump(InputAction.CallbackContext context)
{
if(!_isGrounded) return;
if(_isJumping) return;

_isJumping = true;

_rb.velocity = new Vector2(_jumpDir * _jumpHorizontalSpeed, _jumpForce);
}

其中,InputAction.CallbackContext context是一个按键的详细信息,里面包含了是否按下、输入值是多少等等信息。

角色的动画

详情参见Unity核心-动画基础

角色拾取物品

角色拾取物品本质是看背包中有没有空闲位置,如果有,就将物品添加到背包中,并销毁碰撞到的这个物体的碰撞体。

场景系统

地图系统

地图系统负责这些事情:

  1. 场景构建系统
  • 2D瓦片地图系统

详情参见:Unity核心-Tilemap地图瓦片

  • 3D地形系统

  • 手动放置图形

正处于的地图亮,未去往的地图暗:
Map_Manual

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class MapController_Manual : MonoBehaviour
{
public static MapController_Manual Instance {get; set;}

[SerializeField] private GameObject mapParent; // 存小地图的父对象
private List<Image> mapImages;

public Color highlightColor = Color.white;
public Color dimmedColor = new Color(1f, 1f, 1f, 0.5f);

public RectTransform playerIconTransform;

private void Awake()
{
if(Instance != null && Instance != this)
{
Destroy(gameObject);
}
else
{
Instance = this;
}

mapImages = mapParent.GetComponentsInChildren<Image>().ToList();
}

public void HighlightArea(string areaName)
{
for(int i = 0; i < mapImages.Count; i ++)
{
if(mapImages[i].name == areaName)
{
mapImages[i].color = highlightColor;

playerIconTransform.position = mapImages[i].GetComponent<RectTransform>().position;
}
else
{
mapImages[i].color = dimmedColor;
}
}
}
}
  • 动态随机生成系统

地图不是美术提前画好的,是利用代码在游戏运行时动态生成的。很适合做roguelike或随机生成区域。

Map_Dynamic

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
public class MapController_Dynamic : MonoBehaviour
{
[Header("UI Reference")]
[SerializeField] private GameObject areaPrefab;
[SerializeField] private RectTransform mapParent;
[SerializeField] private RectTransform playerIcon;

[Header("Colours")]
[SerializeField] private Color defaultColor = Color.gray;
[SerializeField] private Color currentAreaColor = Color.green;

[Header("Map Settings")]
[SerializeField] private GameObject mapBounds; // 区域碰撞器的父对象
[SerializeField] private PollygonCollider2D initialArea; // 初始区域(玩家没有存档时)
[SerializeField] private float mapScale = 10f; // 在UI界面上调整地图大小

private PolygonCollider2D[] mapAreas;
private Dictionary<string, RectTransform> uiAreas = new Dictionary<string, RectTransform>();

public stratic MapController_Dynamic Instance { get; set; }

private void Awake()
{
if(Instance == null)
{
Instance = this;
}
else
{
Destory(gameObject);
}

mapAreas = mapBounds.GetComponentSInChildren<PolygonCollider2D>();
}

public void GenerateMap(PolygonCollider2D newCurrentArea = null)
{
ClearMap();

PolygonCollider2D currentArea = newCurrentArea != null ? newCurrentArea : initialArea;

foreach(PolygonCollider2D area in mapAreas)
{
CreateAreaUI(area, area == currentArea);
}

MovePlayerIcon(currentArea.name);
}

// 清空原有地图
private void ClearMap()
{
foreach(Transform child in mapParent)
{
Destroy(child.gameObject);
}

uiAreas.Clear();
}

// 创建UI区域
private CreateAreaUI(PolygonCollider2D area, bool isCurrent)
{
// 得到预制体
GameObject areaImage = Instantiate(areaPrefab, mapParent);
RectTransform rectTransform = areaImage.GetComponent<RectTransform>();

// 获得每个区域的边界
Bounds bounds = area.bounds;

// 缩放
rectTransform.sizeDelta = new Vector2(bounds.size.x * mapScale, bounds.size.y * mapScale);
rectTransform.anchoredPosition = bounds.center * mapScale;

// 添加到字典中
uiAreas[area.name] = rectTransform;
}

// 移动玩家图像
private void MovePlayerIcon(string newCurrentArea)
{
if(uiAreas.TryGetValue(newCurrentArea, out RectTransform areaUI))
{
playerIcon.anchoredPosition = areaUI.anchoredPosition;
}
}

// 更新地图
public void UpdateCurrentArea(string newCurrentArea)
{
foreach(KeyValuePair<string, RectTransform> area in uiAreas)
{
area.Value.GetComponent<Image>().color = area.Key == newCurrentArea ? currenAreaColor : defaultColor;
}
MovePlayerIcon(newCurrentArea);
}
}

其中,有几个细节需要注意:

  • ClearMap()这个函数中,遍历的时候用的是Transform而不是RectTransform,这是因为RectTransformTransform的子类,用Transition遍历最安全、最通用、不会报错。
  • UpdateCurrentArea()这个函数中,遍历的时候用KeyValuePair而不是Dictionary,相当于KeyValuePairDictionary的单数。

以上是生成动态地图的模板,但如何使他随机生成(走到新位置时地图不断扩展)?

以上两种方式都需要配合地图传送脚本应用:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using System.Collections;
using System.Collections.Generic;
using Cinemachine;
using UnityEditor.Experimental.GraphView;
using UnityEngine;

public class MapTransition : MonoBehaviour
{
    [SerializeField] PolygonCollider2D mapBoundry;
    CinemachineConfiner confiner;
    [SerializeField] Direction direction;
    [SerializeField] Transform teleportTargetPosition;
    [SerializeField] float addictivePos = 2f;

    enum Direction {Up, Down, Left, Right, Teleport}

    void Awake()
    {
        confiner = FindObjectOfType<CinemachineConfiner>();
    }

    private void OnTriggerEnter2D(Collider2D collision)
    {
        if(collision.gameObject.CompareTag("Player"))
        {
            confiner.m_BoundingShape2D = mapBoundry;
            UpdatePlayerPosition(collision.gameObject);
           
            MapController_Manual.Instance?.HighlightArea(mapBoundry.name); // mapBoundry是实际的地图边界,但由于每个地图边界的名字都和小地图对应上了,所以能正确高亮小地图

            MapController_Dynamic.Instance?.UpdateCurrentArea(mapBoundry.name);
        }
    }

    // 防止经过传送点又被传送回去
    private void UpdatePlayerPosition(GameObject player)
    {
        if(direction == Direction.Teleport)
        {
            player.transform.position = teleportTargetPosition.position;
            return;
        }

        Vector3 newPos = player.transform.position; // 玩家传送后的位置

        switch(direction)
        {
            case Direction.Up:
                newPos.y += addictivePos;
                break;
            case Direction.Down:
                newPos.y -= addictivePos;
                break;
            case Direction.Left:
                newPos.x -= addictivePos;
                break;
            case Direction.Right:
                newPos.x += addictivePos;
                break;
        }

        player.transform.position = newPos;
    }
}

另外,在切换场景时,可以用淡入淡出的效果,这样会让整个游戏更精致、流畅。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
using System.Collections;
using System.Collections.Generic;
using Cysharp.Threading.Tasks;
using UnityEngine;

public class ScreenFader : MonoBehaviour
{
    public static ScreenFader Instance;
    [SerializeField] CanvasGroup canvasGroup;
    [SerializeField] float fadeDuration = 0.5f;

    private void Awake()
    {
        if(Instance == null) Instance = this;
        else Destroy(gameObject);
    }

    async UniTask Fade(float targetTransparency)
    {
        float start = canvasGroup.alpha, t = 0;
        while(t < fadeDuration)
        {
            t += Time.deltaTime;
            canvasGroup.alpha = Mathf.Lerp(start, targetTransparency, t / fadeDuration);
            await UniTask.Yield();
        }
        canvasGroup.alpha = targetTransparency;
    }

    public async UniTask FadeOut()
    {
        await Fade(1);
    }

    public async UniTask FadeIn()
    {
        await Fade(0);
    }
}
  1. UI导航系统
  • 小地图:显示敌我分布、资源点、任务目标

  • 大地图:查看整个世界区域、传送点、完成度

  • 迷雾系统:地图初始是一片漆黑,玩家走过去后才会驱散迷雾,亮起周围的区域

场景生命周期系统

场景生命周期系统负责这些事情:

  • 场景的加载和卸载(同步\异步加载)
  • 过渡动画与加载条(Loading界面的UI显示、黑屏淡入淡出)
  • 跨场景数据传递(比如从A场景换到B场景,怎么把玩家的血量、装备数据安全带过去)
  • 内存管理(卸载不用的资源,防止游戏闪退)

场景的加载:详情参见Unity之资源管理系统-场景的加载

UI系统

首先要设置:

  • UI Scale Mode:Scale With Screen Size
  • Reference Resolution:1920 x 1080

UI布局

详情参见:UGUI - 常用组件

UI控制

标签页

制作UI有两种方式:

  • 图像 + 事件触发器:含悬停、按下、抬起、拖拽等事件。
    • 适用场景:背包物品拖拽、技能图标悬停Tips、长按事件

下面这个是切换标签页+置灰的操作:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
using UnityEngine;
using UnityEngine.UI;

[SerializeField] private Images[] tabImages; // 标签页
[SerializeField] private GameObject[] pages; // 标签下的具体内容

void Start()
{
ActiveTab(0);
}

public void ActiveTab(int tabNo)
{
for(int i = 0; i < tabImages.Length; i ++)
{
pages[i].SetActive(false);
tabImages[i].color = Color.grey;
}
pages[tabNo].SetActive(true);
tabImages[tabNo].color = Color.white;
}
  • 直接用Button:更简单,支持颜色变换
    • 适用场景:主菜单、确认框、设置界面等常规点击按钮

暂停系统

在游戏控制器对象上新增加一个PauseController.cs

1
2
3
4
5
6
7
8
9
public class PauseController : MonoBehaviour
{
public static bool IsGamePaused { get; private set; } = false;

public static void SetPause(bool pause)
{
IsGamePaused = pause;
}
}

然后修改暂停时需要暂停的东西,比如:

  • 时间
  • 人物移动
1
2
3
4
5
6
7
8
9
10
11
void Update()
{
if(PauseController.IsGamePaused)
    {
rb.velocity = Vector2.zero; // 停止玩家移动
animator.SetBool("isWalking", false);
        return;
    }
    rb.velocity = moveInput * moveSpeed;
    animator.SetBool("isWalking", rb.velocity.magnitude > 0);
}

注意: 如果在打开菜单暂停游戏时按住移动键不松,再次关闭菜单,玩家会从画面上滑过去,这是因为,在游戏暂停的瞬间,我们把rb.velocity设置为0了,但我们依旧没松开方向键,此时的速度可能是(0, 1),取消暂停后立即执行 rb.velocity = moveInput * moveSpeed;,但是玩家动画还没从false变回true,所以有滑过去的视觉效果。因此需要加上animator.SetBool("isWalking", rb.velocity.magnitude > 0);

存档系统

具体参见:存档系统之数据持久化-Json

背包系统

背包布局

想要一个更好看的背包布局界面,可以用以下组件帮助我们:

  • 在背包界面挂载 Grid Layout Group 组件。

  • 在单个槽位上添加 Canvas Group 组件。

详情参见:UGUI-常用组件

背包控制器

以下是一个常用的背包控制器,包含了拖拽物体、交换物体、拖拽至不合理位置还原物体的功能:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
using System.Collections;
using System.Collections.Generic;
using UnityEngine;
using UnityEngine.EventSystems;

public class ItemDragHandler : MonoBehaviour, IBeginDragHandler, IDragHandler, IEndDragHandler
{
    Transform originalParent; // 原始槽位
    CanvasGroup canvasGroup;
   
    void Start()
    {
        canvasGroup = GetComponent<CanvasGroup>();
    }
   
    public void OnBeginDrag(PointerEventData eventData)
    {
        originalParent = transform.parent;
        transform.SetParent(transform.root); // 让物品显示在最上层
        canvasGroup.blocksRaycasts = false; // 关闭射线检测,方便检测拖拽到的新的位置
        canvasGroup.alpha = 0.6f;
    }

    public void OnDrag(PointerEventData eventData)
    {
        transform.position = eventData.position; // 使物品跟随鼠标移动
    }

    public void OnEndDrag(PointerEventData eventData)
    {
        canvasGroup.blocksRaycasts = true;
        canvasGroup.alpha = 1f;

        Slot dropSlot = eventData.pointerEnter?.GetComponent<Slot>();
        // 拖动交换物品
        if(dropSlot == null)
        {
            GameObject dropItem = eventData.pointerEnter; // 鼠标碰到的物体
            if(dropItem != null)
            {
                dropSlot = dropItem.GetComponentInParent<Slot>();
            }
        }
        Slot originalSlot = originalParent.GetComponent<Slot>();
       
        // 最后鼠标落在槽位上
        if(dropSlot != null)
        {
            // 新槽有物品
            if(dropSlot.currentItem != null)
            {
                dropSlot.currentItem.transform.SetParent(originalParent);
                originalSlot.currentItem = dropSlot.currentItem;
                dropSlot.currentItem.GetComponent<RectTransform>().anchoredPosition = Vector2.zero;
            }
            else
            {
                originalSlot.currentItem = null;
            }

            // 被拖动的物品都要进新槽
            transform.SetParent(dropSlot.transform);
            dropSlot.currentItem = gameObject;
        }
        // 最后鼠标没落在槽位上,物品回去
        else
        {
            transform.SetParent(originalParent);
        }

        GetComponent<RectTransform>().anchoredPosition = Vector2.zero;
    }
}

物品堆叠系统

  1. 更新物品数量展示:
1
2
3
4
publi void UpdateQuantityDisplay()
{
quantityText.text = quantity > 1 ? quantity : "";
}
  1. 增加堆叠数量:
1
2
3
4
5
public void AddToStack(int amount = 1)
{
quantity += amount;
UpdateQuatityDisplay();
}
  • 默认参数:int amount = 1味着这个方法带有一个默认值。如果你直接调用 AddToStack() 而不传任何数字,它默认会帮物品数量加 1
  • 数量累加:物品的当前总数(quantity)会加上传入的数值(amount
  1. 减少堆叠:
1
2
3
4
5
6
7
public int RemoveFromStack(int amount = 1)  
{
int removed = Mathf.Min(amount, quantity);
quantity -= removed;
UpdateQuantityDisplay();
return removed;
}
1
2
3
4
5
6
7
8
public GameObject CloneItem(int newQuantity)
{
GameObject clone = Instantiate(gameObject);
Item cloneItem = clone.GetComponent<Item>();
cloneItem.quantity = newQuantity;
cloneItem.UpdateQuantityDisplay();
return clone;
}
  • 防御性编程:这里使用了 Mathf.Min(amount, quantity),这是一个非常规范的防错机制。它会比较你想扣除的数量和你实际拥有的数量,并返回其中较小的那个值。

道具系统

道具通常分为可交互物品不可交互物品

在编写可交互物品时,我们通常这样做:

  • 写一个接口:Interactable.cs
  • 对于可交互物品,使他继承这个接口,处理对应逻辑,里面可以包含void Interact()bool CanInteract()

注意: 我们可以在生成物品唯一ID时,写一个公共静态类 GlobalHelper.cs ,里面放一些公共静态方法,比如:GenerateUniqueID() 简单快捷。

  • 给玩家添加交互检测器,以及编写一个InteractionDetector.cs,当进入Trigger范围内可交互;走出后不能交互

商店系统

M

CurrencyController.cs 货币管理器

职责:记录初始金币数和当前金币数,监听金币改变的事件

V

这是一个同时支持玩家出售物品和从商店购买商品两项功能的商店设计示例。商店界面的UI设计如下:

ShopMenu

  • TitleTab(商店标签)
  • ShopContent
    • PlayerInventoryArea(玩家背包区域)
      • PlayerInvTitle
      • Scroll View
        • 关键在于Content(负责滚动区域尺寸计算)里有个PlayerInvGrid(专门承载Grid Layout Group + Content Size Fitter)。这个区域代表背包所有槽位的主界面。
    • ShopInventoryArea(商店库存区域)
  • PlayerMoneyText
  • CloseButton

C

ShopController.cs 商店管理器

职责:控制商店界面的UI显示(开启\关闭商店、更新商店界面显示、更新玩家界面显示)

对话系统和玩家对话选项

对话系统由三部分组成:NPCDialogue.csScriptableObject)、DialogueUIController.csNPC.cs

NPCDialogue.cs

包含NPC名字、头像、要说的话、是否自动推进数组、自动推进延迟时间、打字速度,还可以加上音频,音高。

当然,也可以设置玩家对话选项。

以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
[CreateAssetMenu(fileName = "NewNPCDialogue", menuName = "NPC Dialogue")]
public class NPCDialogue : ScriptableObject
{
    public string npcName;
    public Sprite npcPortrait;

    public string[] dialogueLines;
    public bool[] autoProgressLines;
    public bool[] endDialogueLines; // 标记在哪对话结束
    public float autoProgressDelay = 1.5f;
    public float typingSpeed = 0.05f;

    public AudioClip voiceSound;
    public float voicePitch = 1f;
   
    public DialogueChoice[] choices;
}

[System.Serializable]
public class DialogueChoice
{
    public int dialogueIndex; // 需要选项对应的对话索引
    public string[] choices; // 选项字符串组
    public int[] nextDialogueIndexes; // 根据不同选项关联的下一句对话的索引
}

DialogueUIController.cs

这个脚本将作为一个单例模式,包含玩家的对话面板、NPC名字、对话文本、NPC头像、选择面板容器以及选择按钮预制件。

承担这些功能:控制对话面板的显隐、设置NPC姓名和头像、设置对话信息、清空选项池、创建选项按钮。

NPC.cs

这个脚本需要引用NPC对话数据、DialogueUIController、继承可交互的接口,实现接口方法。大概逻辑如下:

  • CanInteract()返回是否可交互,根据是否正在对话判断
  • Interact()处理对应交互逻辑:
    • 对话数据为空 || (游戏正在暂停 && 对话面板未激活) return;
    • if(正在对话) NextLine();
    • else StartDialogue();

其中需要注意的是:

  • StartDialogue()中需要暂停游戏,防止玩家对话时还能走动

  • NextLine()中如果正在打字需要处理跳过打字机动画完整显示下一行的判断,如果有选项可以选择需要先检查是否在此结束对话,再检查是否有对话选项,然后再展示选项

  • 打字机动画和自动推进效果通过协程实现

任务系统

实现任务系统的思路是:

配置层(Scriptable Object)

配置层的结构:

  • 任务(任务ID、任务名称、任务描述、目标列表
    • 目标1(目标ID、目标描述、目标类型、目标参数、目标是否完成)
    • 目标2
  • 任务进度(对应任务、目标列表、任务是否完成)

这是一个常见的任务的Scriptable Object模板:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
using System;
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

[CreateAssetMenu(menuName = "Quests/Quest")]
public class Quest : ScriptableObject
{
    public string questID;
    public string questName;
    public string description;
    public List<QuestObjective> objectives;

    private void OnValidate()
    {
        if(string.IsNullOrEmpty(questID))
        {
            questID = questName + Guid.NewGuid().ToString();
        }
    }   
}

[System.Serializable]
public class QuestObjective
{
public string objectiveID;
public string description;
public ObjectiveType type;
public int requiredAmount;
public int currentAmount;

public bool IsCompleted => currentAmount >= requiredAmount;
}

public enum ObjectiveType { CollectItem, DefeatEnemy, ReachLocation, TalkNPC, Custom }

[System.Serializable]
public class QuestProgress
{
public Quest quest;
public List<QuestObjective> objectives;

public QuestProgress(Quest quest)
{
this.quest = quest;
objectives = new List<QuestObjective>();

// 深拷贝
foreach(var obj in quest.objectives)
{
objectives.Add(new QuestObjective
{
objectiveID = obj.objectiveID,
description = obj.description,
type = obj.type,
requiredAmount = obj.requiredAmount,
currentAmount = 0
});
}
}

public bool IsCompleted => objectives.TrueForAll(o => o.IsCompleted);
public string QuestID => quest.questID;
}

逻辑层

需要一个QuestController(单例),实现数据(任务是否被接受、是否完成)与UI(得到对应任务UI,更新UI)上的连接。

表现层

QuestUI,拖入对应的UI控件,实现更新UI面板的功能(销毁存在的任务条目、创建新的任务条目)。

完成这些准备工作后,我们需要通过与NPC交谈动态的接取或完成任务。

音频系统

详情参见Unity之音频系统

NPC导航寻路系统

航点寻路

航点寻路的主要思路是:创建一些特定的路径点,加入路径点数组,使玩家可以依次移动到对应路径点,以下是一个示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
using System.Collections;
using System.Collections.Generic;
using UnityEngine;

public class WaypointMover : MonoBehaviour
{
    public Transform waypointParent;
    public float moveSpeed = 2f;
    public float waitTime = 2f;
    public bool loopWaypoints = true;

    private Transform[] waypoints;
    private int currentWaypointIndex;
    private bool isWaiting;

    void Start()
    {
        waypoints = new Transform[waypointParent.childCount];

        for(int i = 0; i < waypointParent.childCount; i ++)
        {
            waypoints[i] = waypointParent.GetChild(i);
        }
    }

    void Update()
    {
        if(PauseController.IsGamePaused || isWaiting)
        {
            return;
        }

        MoveToWaypoint();
    }

    void MoveToWaypoint()
    {
        Transform target = waypoints[currentWaypointIndex];

        transform.position = Vector2.MoveTowards(transform.position, target.position, moveSpeed * Time.deltaTime);

        if(Vector2.Distance(transform.position, target.position) < 0.1f)
        {
            StartCoroutine(WaitAtWaypoint());
        }
    }

    IEnumerator WaitAtWaypoint()
    {
        isWaiting = true;
        yield return new WaitForSeconds(waitTime);

        currentWaypointIndex = loopWaypoints ? (currentWaypointIndex + 1) % waypoints.Length :  Mathf.Min(currentWaypointIndex + 1, waypoints.Length - 1);
       
        isWaiting = false;
    }
}

关于GameController

在实际开发中,游戏系统一多便很难管理。因此,我们可以设定一个游戏全局管理器:GameController。在GameController上有两种挂载脚本的方案:

  1. 脚本直接挂GameController本体。这种方式通常适用于数据核心,不涉及UI交互的
  2. 子空物体挂载独立Controller。功能分离,UI、场景、音频

这样处理的目的是:

  • 直接挂GameController本体
    • 数据模块直接依附主物体,查找层级单一,单例逻辑简单
    • 生命周期完全绑定,核心数据同步常驻
    • 数据关联性强,调试集中
    • 轻量化,组件数量少
  • 单独新建子物体
    • 职责分离,方便查找
    • 方便管理UI资源
    • 支持单独开关、临时禁用模块