Skip to content

UI 与业务

背包系统怎么设计?

背包系统怎么设计?

一句话:背包系统不要只理解成“UI 格子”,它本质是一个“物品配置 + 格子数据 + 背包规则 + UI 刷新 + 存档”的小型数据系统。

inventory-system-design-csharp

核心拆法

背包系统通常分 5 层:

  1. ItemConfig:物品配置,描述“物品是什么”,比如 id、名字、图标、类型、最大堆叠数。
  2. SlotData:格子数据,描述“玩家有什么”,比如这个格子里是哪个 itemId、数量是多少。
  3. Inventory:背包逻辑,负责添加、删除、交换、拆分、合并、排序。
  4. InventoryUI:背包界面,只负责显示图标、数量、选中态、拖拽,不应该直接改核心数据。
  5. SaveData:存档数据,只存 itemId + count,不要存图标、名字这些配置内容。

为什么要这样设计

如果把逻辑写在 UI 里,后面做仓库、商店、快捷栏、装备栏时会非常痛苦。

正确做法是: UI 点击按钮 → 调用背包逻辑 → 背包数据变化 → 发事件通知 UI 刷新。

这样背包系统可以被多个地方复用,比如:

  • 背包界面显示物品
  • 商店购买后添加物品
  • 怪物掉落后添加物品
  • 使用药水后减少物品
  • 装备系统读取背包里的装备
  • 存档系统保存背包内容

添加物品的核心流程

比如获得 12 个药水:

  1. 先查配置,看这个物品是否存在。
  2. 如果药水可以堆叠,比如最大堆叠 99
  3. 先找已有药水但没满的格子。
  4. 能叠多少叠多少。
  5. 如果还有剩余,再找空格子放进去。
  6. 如果空格也不够,就添加失败,或者返回剩余数量。

C# 简化代码示例

c
using System; // 引入 Action,用来做背包变化事件。
using System.Collections.Generic; // 引入 List 和 Dictionary,用来存背包格子和物品配置。
public sealed class ItemConfig // 定义物品配置类,表示一种物品的静态信息。
{ // ItemConfig 类开始。
    public int Id { get; } // 物品唯一 id,例如 1001 表示药水。
    public string Name { get; } // 物品名字,例如“生命药水”。
    public int MaxStack { get; } // 最大堆叠数量,例如药水可以叠 99 个。
    public ItemConfig(int id, string name, int maxStack) // 构造函数,用来创建物品配置。
    { // 构造函数开始。
        Id = id; // 保存物品 id。
        Name = name; // 保存物品名字。
        MaxStack = maxStack; // 保存最大堆叠数量。
    } // 构造函数结束。
} // ItemConfig 类结束。
public sealed class SlotData // 定义背包格子数据类,表示一个格子里有什么。
{ // SlotData 类开始。
    public int ItemId; // 当前格子的物品 id,0 表示空格子。
    public int Count; // 当前格子的物品数量。
    public bool IsEmpty => ItemId == 0 || Count <= 0; // 判断这个格子是不是空的。
    public void Clear() // 清空当前格子。
    { // Clear 方法开始。
        ItemId = 0; // 把物品 id 设置为 0。
        Count = 0; // 把数量设置为 0。
    } // Clear 方法结束。
} // SlotData 类结束。
public sealed class Inventory // 定义背包逻辑类,真正管理背包数据。
{ // Inventory 类开始。
    private readonly List<SlotData> _slots; // 保存所有背包格子。
    private readonly Dictionary<int, ItemConfig> _configs; // 保存所有物品配置,方便通过 itemId 查询。
    public event Action OnChanged; // 背包变化事件,UI 可以监听它来刷新界面。
    public Inventory(int capacity, Dictionary<int, ItemConfig> configs) // 构造函数,创建指定容量的背包。
    { // 构造函数开始。
        _configs = configs; // 保存物品配置表引用。
        _slots = new List<SlotData>(capacity); // 创建格子列表,并预分配容量。
        for (int i = 0; i < capacity; i++) // 根据容量循环创建格子。
        { // for 循环开始。
            _slots.Add(new SlotData()); // 添加一个空格子。
        } // for 循环结束。
    } // 构造函数结束。
    public bool AddItem(int itemId, int count) // 添加物品,成功返回 true,失败返回 false。
    { // AddItem 方法开始。
        if (count <= 0) return false; // 如果数量不合法,直接失败。
        if (!_configs.TryGetValue(itemId, out ItemConfig config)) return false; // 如果配置表里没有这个物品,直接失败。
        for (int i = 0; i < _slots.Count; i++) // 第一轮循环,优先找同类物品进行堆叠。
        { // 第一轮 for 开始。
            SlotData slot = _slots[i]; // 取出当前格子。
            if (slot.ItemId != itemId) continue; // 如果不是同一种物品,就跳过。
            int space = config.MaxStack - slot.Count; // 计算当前格子还能放多少个。
            if (space <= 0) continue; // 如果这个格子已经满了,就跳过。
            int add = Math.Min(space, count); // 计算本次实际能放进去多少个。
            slot.Count += add; // 增加当前格子的数量。
            count -= add; // 减少还没放进去的数量。
            if (count == 0) // 如果已经全部放完。
            { // if 开始。
                OnChanged?.Invoke(); // 通知 UI 背包发生变化。
                return true; // 返回添加成功。
            } // if 结束。
        } // 第一轮 for 结束。
        for (int i = 0; i < _slots.Count; i++) // 第二轮循环,找空格子放剩余物品。
        { // 第二轮 for 开始。
            SlotData slot = _slots[i]; // 取出当前格子。
            if (!slot.IsEmpty) continue; // 如果不是空格子,就跳过。
            int add = Math.Min(config.MaxStack, count); // 计算这个空格最多能放多少个。
            slot.ItemId = itemId; // 设置当前格子的物品 id。
            slot.Count = add; // 设置当前格子的物品数量。
            count -= add; // 减少还没放进去的数量。
            if (count == 0) // 如果已经全部放完。
            { // if 开始。
                OnChanged?.Invoke(); // 通知 UI 背包发生变化。
                return true; // 返回添加成功。
            } // if 结束。
        } // 第二轮 for 结束。
        OnChanged?.Invoke(); // 通知 UI 刷新,因为可能已经放入了一部分。
        return false; // 返回失败,表示背包空间不足。
    } // AddItem 方法结束。
} // Inventory 类结束。

面试高分说法

NOTE

背包系统我会拆成数据层、逻辑层、表现层和存档层。物品本身用配置表表示,玩家拥有的物品只保存 itemIdcount。添加物品时先查配置,再优先堆叠已有格子,剩余数量再放空格。UI 不直接修改数据,只监听背包变化事件刷新显示。这样背包、仓库、商店、装备栏、快捷栏都可以复用同一套底层逻辑。

商店系统怎么设计?

商店系统怎么设计?

商店系统不是一个商品 UI 列表,而是一套交易系统:商品配置、商店库存、价格、购买校验、扣货币、发物品、出售、刷新、日志、保存和服务端同步。

shop-system-design-csharp

核心模块

商品配置:商品 ID、物品 ID、价格、货币类型、限购次数、最大库存、是否打折。

商店运行数据:当前库存、玩家已购买次数、刷新时间、随机商品列表。

货币系统:金币、钻石、代币等资源扣除和增加。

背包系统:购买成功后发物品,出售时移除物品。

商店逻辑:购买、出售、刷新、限购、折扣、库存检查。

UI 层:只负责显示商品、价格、按钮状态,不直接改数据。

购买流程

玩家点击购买。

系统检查商品是否存在。

检查库存是否足够。

检查是否超过限购。

检查玩家货币是否足够。

检查背包是否有空间。

全部通过后,扣货币、加物品、减少库存、增加购买次数。

最后刷新 UI,并记录交易日志。

C# 简化示例:商店购买核心

c
using System.Collections.Generic; // 引入集合命名空间,用来使用 Dictionary
public enum CurrencyType { Gold, Diamond } // 定义货币类型,包括金币和钻石
public class ShopItemConfig // 定义商店商品配置
{ // 类开始
    public int shopItemId; // 保存商店商品 ID
    public int itemId; // 保存实际物品 ID
    public int count; // 保存购买一次获得的物品数量
    public CurrencyType currencyType; // 保存购买需要的货币类型
    public int price; // 保存购买价格
    public int maxBuyCount; // 保存最大限购次数,0 表示不限购
    public int stock; // 保存商品库存,-1 表示无限库存
} // 类结束
public class ShopRuntimeItem // 定义商店商品运行时数据
{ // 类开始
    public ShopItemConfig config; // 保存商品配置
    public int boughtCount; // 保存玩家已经购买次数
    public int currentStock; // 保存当前剩余库存
} // 类结束
public class Wallet // 定义玩家钱包
{ // 类开始
    private Dictionary<CurrencyType, int> currencies = new Dictionary<CurrencyType, int>(); // 保存玩家拥有的各种货币数量
    public bool HasEnough(CurrencyType type, int amount) // 定义检查货币是否足够的函数
    { // 函数开始
        return currencies.TryGetValue(type, out int value) && value >= amount; // 返回指定货币是否存在且数量足够
    } // 函数结束
    public void Cost(CurrencyType type, int amount) // 定义扣除货币函数
    { // 函数开始
        currencies[type] -= amount; // 从指定货币中扣除数量
    } // 函数结束
} // 类结束
public class Inventory // 定义简化背包
{ // 类开始
    public bool CanAdd(int itemId, int count) // 定义检查背包是否能添加物品的函数
    { // 函数开始
        return true; // 示例中简化为一定能添加,真实项目要检查容量和堆叠
    } // 函数结束
    public void AddItem(int itemId, int count) // 定义添加物品函数
    { // 函数开始
    } // 函数结束
} // 类结束
public class ShopSystem // 定义商店系统
{ // 类开始
    private Wallet wallet; // 保存玩家钱包引用
    private Inventory inventory; // 保存玩家背包引用
    private Dictionary<int, ShopRuntimeItem> items = new Dictionary<int, ShopRuntimeItem>(); // 保存商店中的商品运行时数据
    public ShopSystem(Wallet wallet, Inventory inventory) // 定义商店系统构造函数
    { // 构造函数开始
        this.wallet = wallet; // 保存钱包引用
        this.inventory = inventory; // 保存背包引用
    } // 构造函数结束
    public bool Buy(int shopItemId) // 定义购买商品函数
    { // 函数开始
        if (items.TryGetValue(shopItemId, out ShopRuntimeItem item) == false) // 判断商品是否不存在
        { // if 开始
            return false; // 商品不存在就购买失败
        } // if 结束
        if (item.config.stock != -1 && item.currentStock <= 0) // 判断商品是否有库存限制且库存不足
        { // if 开始
            return false; // 库存不足就购买失败
        } // if 结束
        if (item.config.maxBuyCount > 0 && item.boughtCount >= item.config.maxBuyCount) // 判断是否超过限购次数
        { // if 开始
            return false; // 超过限购就购买失败
        } // if 结束
        if (wallet.HasEnough(item.config.currencyType, item.config.price) == false) // 判断玩家货币是否不足
        { // if 开始
            return false; // 货币不足就购买失败
        } // if 结束
        if (inventory.CanAdd(item.config.itemId, item.config.count) == false) // 判断背包是否不能添加物品
        { // if 开始
            return false; // 背包空间不足就购买失败
        } // if 结束
        wallet.Cost(item.config.currencyType, item.config.price); // 扣除购买需要的货币
        inventory.AddItem(item.config.itemId, item.config.count); // 把物品加入背包
        item.boughtCount++; // 增加玩家已购买次数
        if (item.config.stock != -1) item.currentStock--; // 如果不是无限库存,就减少当前库存
        return true; // 返回购买成功
    } // 函数结束
} // 类结束

设计重点

购买要有原子性:不能扣了钱但物品没发成功。

UI 不要直接改背包和货币,要调用商店系统接口。

联网游戏里,价格、库存、购买次数必须服务端校验,客户端不能自己决定购买成功。

随机商店要记录刷新时间、商品池、随机种子或最终结果,避免刷新后对不上。

出售系统要检查物品是否可出售、是否绑定、出售价格和数量是否合法。

面试高分回答

TIP

商店系统我会拆成配置层、数据层、逻辑层和表现层。配置层保存商品 ID、物品 ID、价格、货币类型、限购和库存;数据层保存当前库存、已购买次数和刷新时间;逻辑层提供购买、出售、刷新、折扣和限购校验;表现层只监听数据刷新 UI。购买流程必须先校验商品、库存、限购、货币和背包容量,全部通过后再扣货币、发物品、更新库存和记录日志。联网游戏中商店应由服务端权威处理,客户端只发送购买请求和展示结果,防止玩家改价格、改库存或伪造购买。

任务系统怎么设计?

任务系统怎么设计?

这里的“任务系统”我按游戏里的 Quest / Mission 系统来讲。它不是单纯的“任务列表 UI”,而是一套“配置表 + 玩家进度 + 事件推进 + 状态机 + 奖励 + 存档”的系统。

quest-system-design-csharp

一句话理解

任务系统的本质是:玩家做了某件事,比如杀怪、拾取、对话、进入区域,任务系统收到事件后检查是否符合任务目标,如果符合就增加进度,进度满了就进入“可领取奖励”状态。

核心模块

任务配置:定义任务是什么,比如任务 id、标题、描述、目标、奖励、前置任务。

玩家任务数据:记录玩家当前做到哪里了,比如任务状态、每个目标完成了多少。

任务管理器:负责接任务、更新进度、判断完成、发奖励、通知 UI。

事件系统:战斗、背包、对话、地图等系统不直接改任务,而是发事件给任务系统。

任务 UI:只负责显示任务列表、任务追踪、进度条、奖励按钮,不应该写任务规则。

任务状态一般怎么设计

常见状态可以这样分:

  • Locked:未解锁,比如前置任务没完成。
  • CanAccept:可以接取。
  • InProgress:进行中。
  • CanReward:目标完成,可以领奖。
  • Rewarded:奖励已领取,任务彻底完成。

这样比只用一个 bool isDone 强很多,因为任务从“能接”到“做完”中间有多个阶段。

C# 代码示例

c
using System; // 引入 Action,用来做任务变化通知事件。
using System.Collections.Generic; // 引入 List 和 Dictionary,用来保存任务配置和任务进度。
public enum QuestState // 定义任务状态枚举。
{ // QuestState 枚举开始。
    Locked, // 未解锁,玩家暂时不能接这个任务。
    CanAccept, // 可接取,玩家可以从 NPC 或任务面板接任务。
    InProgress, // 进行中,玩家正在完成任务目标。
    CanReward, // 可领奖,任务目标已经完成但奖励还没领取。
    Rewarded // 已领奖,任务彻底完成。
} // QuestState 枚举结束。
public enum ObjectiveType // 定义任务目标类型枚举。
{ // ObjectiveType 枚举开始。
    Kill, // 击杀目标,例如击杀 3 只史莱姆。
    Collect, // 收集目标,例如收集 5 个草药。
    Talk // 对话目标,例如和村长对话。
} // ObjectiveType 枚举结束。
public sealed class ObjectiveConfig // 定义任务目标配置类。
{ // ObjectiveConfig 类开始。
    public ObjectiveType Type; // 目标类型,例如 Kill、Collect、Talk。
    public int TargetId; // 目标 id,例如怪物 id、物品 id、NPC id。
    public int NeedCount; // 需要完成的数量,例如击杀 3 只怪。
} // ObjectiveConfig 类结束。
public sealed class QuestConfig // 定义任务配置类。
{ // QuestConfig 类开始。
    public int QuestId; // 任务唯一 id。
    public string Title; // 任务标题。
    public List<ObjectiveConfig> Objectives = new List<ObjectiveConfig>(); // 任务目标列表,一个任务可以有多个目标。
} // QuestConfig 类结束。
public sealed class ObjectiveProgress // 定义单个目标的运行时进度。
{ // ObjectiveProgress 类开始。
    public int Current; // 当前完成数量。
} // ObjectiveProgress 类结束。
public sealed class QuestData // 定义玩家身上的任务数据。
{ // QuestData 类开始。
    public int QuestId; // 当前数据对应的任务 id。
    public QuestState State; // 当前任务状态。
    public List<ObjectiveProgress> Progress = new List<ObjectiveProgress>(); // 每个目标对应的进度。
} // QuestData 类结束。
public readonly struct GameEvent // 定义游戏事件结构体。
{ // GameEvent 结构体开始。
    public readonly ObjectiveType Type; // 事件类型,例如击杀、收集、对话。
    public readonly int TargetId; // 事件目标 id,例如被杀怪物 id。
    public readonly int Count; // 本次事件增加的数量。
    public GameEvent(ObjectiveType type, int targetId, int count) // 构造一个游戏事件。
    { // 构造函数开始。
        Type = type; // 保存事件类型。
        TargetId = targetId; // 保存目标 id。
        Count = count; // 保存数量。
    } // 构造函数结束。
} // GameEvent 结构体结束。
public sealed class QuestManager // 定义任务管理器。
{ // QuestManager 类开始。
    private readonly Dictionary<int, QuestConfig> _configs; // 保存所有任务配置。
    private readonly Dictionary<int, QuestData> _datas = new Dictionary<int, QuestData>(); // 保存玩家当前任务数据。
    public event Action<int> OnQuestChanged; // 任务变化事件,参数是变化的任务 id。
    public QuestManager(Dictionary<int, QuestConfig> configs) // 构造任务管理器。
    { // 构造函数开始。
        _configs = configs; // 保存配置表引用。
    } // 构造函数结束。
    public bool AcceptQuest(int questId) // 接取任务。
    { // AcceptQuest 方法开始。
        if (!_configs.TryGetValue(questId, out QuestConfig config)) return false; // 如果配置不存在,接取失败。
        if (_datas.ContainsKey(questId)) return false; // 如果已经有这个任务数据,说明不能重复接。
        QuestData data = new QuestData(); // 创建玩家任务数据。
        data.QuestId = questId; // 记录任务 id。
        data.State = QuestState.InProgress; // 接取后进入进行中状态。
        for (int i = 0; i < config.Objectives.Count; i++) // 根据配置里的目标数量创建进度。
        { // for 循环开始。
            data.Progress.Add(new ObjectiveProgress()); // 添加一个目标进度,默认 Current 为 0。
        } // for 循环结束。
        _datas.Add(questId, data); // 把任务数据加入玩家任务字典。
        OnQuestChanged?.Invoke(questId); // 通知 UI 刷新这个任务。
        return true; // 返回接取成功。
    } // AcceptQuest 方法结束。
    public void OnGameEvent(GameEvent gameEvent) // 收到游戏事件后推进任务进度。
    { // OnGameEvent 方法开始。
        foreach (QuestData data in _datas.Values) // 遍历玩家身上的所有任务。
        { // foreach 循环开始。
            if (data.State != QuestState.InProgress) continue; // 只有进行中的任务才需要更新进度。
            QuestConfig config = _configs[data.QuestId]; // 根据任务 id 找到任务配置。
            for (int i = 0; i < config.Objectives.Count; i++) // 遍历这个任务的所有目标。
            { // for 循环开始。
                ObjectiveConfig objective = config.Objectives[i]; // 取出当前目标配置。
                if (objective.Type != gameEvent.Type) continue; // 如果事件类型不匹配,就跳过。
                if (objective.TargetId != gameEvent.TargetId) continue; // 如果目标 id 不匹配,就跳过。
                ObjectiveProgress progress = data.Progress[i]; // 取出当前目标进度。
                progress.Current = Math.Min(progress.Current + gameEvent.Count, objective.NeedCount); // 增加进度,但不能超过需求数量。
            } // for 循环结束。
            if (IsAllObjectivesDone(config, data)) data.State = QuestState.CanReward; // 如果所有目标完成,就进入可领奖状态。
            OnQuestChanged?.Invoke(data.QuestId); // 通知 UI 这个任务进度变化。
        } // foreach 循环结束。
    } // OnGameEvent 方法结束。
    private bool IsAllObjectivesDone(QuestConfig config, QuestData data) // 判断任务是否所有目标都完成。
    { // IsAllObjectivesDone 方法开始。
        for (int i = 0; i < config.Objectives.Count; i++) // 遍历所有目标。
        { // for 循环开始。
            if (data.Progress[i].Current < config.Objectives[i].NeedCount) return false; // 只要有一个目标没完成,就返回 false。
        } // for 循环结束。
        return true; // 所有目标都完成,返回 true。
    } // IsAllObjectivesDone 方法结束。
} // QuestManager 类结束。

面试高分说法

NOTE

任务系统我会做成事件驱动。任务配置表只描述任务目标和奖励,玩家数据只保存任务状态和目标进度。战斗系统、背包系统、对话系统不会直接修改任务,而是发出 KillCollectTalk 这类事件,任务管理器监听事件后匹配任务目标并更新进度。任务完成后进入 CanReward 状态,玩家领奖后进入 Rewarded 状态。这样任务系统和其他系统耦合低,后续扩展每日任务、成就、主线、支线、活动任务都比较方便。

对话系统怎么设计?

对话系统怎么设计?

对话系统可以理解成“节点跳转系统”:NPC 说一句话是一个节点,玩家点一个选项,就跳到另一个节点;选项还可以带条件和行为,比如“任务完成后才显示”“点击后接任务”“点击后打开商店”。

dialogue-system-design-csharp

核心拆法

对话配置:保存 NPC 名字、对话节点、起始节点、选项分支。

对话节点:一条 NPC 台词,比如“年轻人,你愿意帮我找草药吗?”

玩家选项:玩家能点的按钮,比如“愿意”“不愿意”“打开商店”。

条件判断:控制选项能不能出现,比如任务未完成、背包有钥匙、好感度足够。

触发行为:点击选项后做什么,比如接任务、发奖励、打开商店、设置剧情变量。

UI 层:只负责显示 NPC 名字、文字、选项按钮、打字机效果,不要把任务逻辑写进 UI。

C# 核心代码

c
using System; // 引入 Action,用来通知 UI 对话节点变化。
using System.Collections.Generic; // 引入 List 和 Dictionary,用来保存节点和选项。
public enum DialogueActionType // 定义对话选项点击后要执行的行为类型。
{ // DialogueActionType 枚举开始。
    None, // 什么都不做,只跳到下一个节点。
    StartQuest, // 接取任务。
    OpenShop, // 打开商店。
    SetFlag // 设置剧情标记。
} // DialogueActionType 枚举结束。
public sealed class DialogueOption // 定义玩家可以点击的一个对话选项。
{ // DialogueOption 类开始。
    public string Text; // 选项显示文本,例如“我愿意帮忙”。
    public int NextNodeId; // 点击后跳转到哪个节点,-1 表示结束对话。
    public string RequiredFlag; // 显示该选项需要的剧情标记,空字符串表示不需要条件。
    public DialogueActionType ActionType; // 点击该选项后执行的行为类型。
    public int ActionParam; // 行为参数,例如任务 id 或商店 id。
} // DialogueOption 类结束。
public sealed class DialogueNode // 定义一个对话节点,也就是 NPC 的一句话。
{ // DialogueNode 类开始。
    public int NodeId; // 节点唯一 id。
    public string Speaker; // 说话者名字,例如“村长”。
    public string Content; // 对话正文,例如“请帮我找 3 个草药”。
    public List<DialogueOption> Options = new List<DialogueOption>(); // 当前节点下面的玩家选项。
} // DialogueNode 类结束。
public sealed class DialogueConfig // 定义一整段对话配置。
{ // DialogueConfig 类开始。
    public int DialogueId; // 对话 id,例如村长第一次见面的对话。
    public int StartNodeId; // 起始节点 id。
    public Dictionary<int, DialogueNode> Nodes = new Dictionary<int, DialogueNode>(); // 所有节点,通过 nodeId 快速查找。
} // DialogueConfig 类结束。
public sealed class DialogueManager // 定义对话管理器,负责推进对话流程。
{ // DialogueManager 类开始。
    private DialogueConfig _currentConfig; // 当前正在播放的对话配置。
    private DialogueNode _currentNode; // 当前显示的对话节点。
    private readonly HashSet<string> _flags = new HashSet<string>(); // 保存剧情标记,例如“TalkedToVillageChief”。
    private readonly List<DialogueOption> _visibleOptions = new List<DialogueOption>(); // 保存当前真正能显示的选项。
    public event Action<DialogueNode, IReadOnlyList<DialogueOption>> OnNodeChanged; // 节点变化事件,UI 监听后刷新文本和按钮。
    public void Begin(DialogueConfig config) // 开始一段对话。
    { // Begin 方法开始。
        _currentConfig = config; // 保存当前对话配置。
        GoToNode(config.StartNodeId); // 跳转到起始节点。
    } // Begin 方法结束。
    public void Choose(int visibleOptionIndex) // 玩家点击某个可见选项。
    { // Choose 方法开始。
        if (visibleOptionIndex < 0 || visibleOptionIndex >= _visibleOptions.Count) return; // 如果选项下标非法,就直接返回。
        DialogueOption option = _visibleOptions[visibleOptionIndex]; // 取出玩家点击的选项。
        ExecuteAction(option); // 先执行选项携带的行为。
        if (option.NextNodeId < 0) End(); // 如果 nextNodeId 小于 0,就结束对话。
        else GoToNode(option.NextNodeId); // 否则跳转到下一个对话节点。
    } // Choose 方法结束。
    private void GoToNode(int nodeId) // 跳转到指定节点。
    { // GoToNode 方法开始。
        if (!_currentConfig.Nodes.TryGetValue(nodeId, out _currentNode)) return; // 如果找不到节点,就直接返回。
        _visibleOptions.Clear(); // 清空上一个节点留下来的可见选项。
        foreach (DialogueOption option in _currentNode.Options) // 遍历当前节点的所有选项。
        { // foreach 循环开始。
            if (CanShow(option)) _visibleOptions.Add(option); // 如果选项条件满足,就加入可见选项列表。
        } // foreach 循环结束。
        OnNodeChanged?.Invoke(_currentNode, _visibleOptions); // 通知 UI 刷新 NPC 文本和选项按钮。
    } // GoToNode 方法结束。
    private bool CanShow(DialogueOption option) // 判断某个选项能不能显示。
    { // CanShow 方法开始。
        if (string.IsNullOrEmpty(option.RequiredFlag)) return true; // 如果没有条件,就允许显示。
        return _flags.Contains(option.RequiredFlag); // 如果有条件,就检查玩家是否拥有对应剧情标记。
    } // CanShow 方法结束。
    private void ExecuteAction(DialogueOption option) // 执行选项携带的行为。
    { // ExecuteAction 方法开始。
        if (option.ActionType == DialogueActionType.StartQuest) StartQuest(option.ActionParam); // 如果是接任务行为,就调用接任务逻辑。
        if (option.ActionType == DialogueActionType.OpenShop) OpenShop(option.ActionParam); // 如果是打开商店行为,就调用商店逻辑。
        if (option.ActionType == DialogueActionType.SetFlag) SetFlag("Flag_" + option.ActionParam); // 如果是设置标记行为,就写入剧情标记。
    } // ExecuteAction 方法结束。
    private void StartQuest(int questId) // 接取任务。
    { // StartQuest 方法开始。
        Console.WriteLine("接取任务:" + questId); // 示例输出,实际项目里应调用 QuestManager。
    } // StartQuest 方法结束。
    private void OpenShop(int shopId) // 打开商店。
    { // OpenShop 方法开始。
        Console.WriteLine("打开商店:" + shopId); // 示例输出,实际项目里应调用 ShopManager。
    } // OpenShop 方法结束。
    private void SetFlag(string flag) // 设置剧情标记。
    { // SetFlag 方法开始。
        _flags.Add(flag); // 把剧情标记加入集合。
    } // SetFlag 方法结束。
    private void End() // 结束对话。
    { // End 方法开始。
        _currentNode = null; // 清空当前节点。
        _visibleOptions.Clear(); // 清空当前可见选项。
        OnNodeChanged?.Invoke(null, _visibleOptions); // 通知 UI 关闭对话界面。
    } // End 方法结束。
} // DialogueManager 类结束。

面试高分说法

NOTE

对话系统我会做成节点式、数据驱动。每个节点表示一句话,每个选项表示一个分支,选项上可以挂条件和行为。条件用来判断选项能不能显示,比如任务状态、背包道具、剧情标记;行为用来触发外部系统,比如接任务、打开商店、发奖励。UI 只监听 DialogueManager 的节点变化并刷新显示,不直接处理任务或背包逻辑。这样对话系统和任务、商店、背包系统耦合比较低,后续扩展分支剧情、多语言、本地化、打字机效果、剧情回放也更方便。

新手引导怎么设计?

新手引导怎么设计?

新手引导本质是一个“按步骤播放的教学系统”:系统检测玩家满足条件后,显示遮罩、高亮目标按钮、提示玩家操作;玩家完成指定动作后,推进到下一步,最后把已完成状态存档。

new-player-guide-system-design-csharp

核心拆法

新手引导不要写死在某个 UI 按钮里,最好拆成几层:

  1. 引导配置:描述第几步、指向哪个 UI、提示什么文字、完成条件是什么。
  2. 引导管理器:负责开始引导、进入步骤、等待玩家操作、推进下一步。
  3. 引导 UI:负责遮罩、高亮、箭头、提示气泡、手指动画。
  4. 事件系统:玩家点击按钮、打开界面、完成强化时,发事件给引导系统。
  5. 存档数据:记录哪些引导已经完成,避免玩家每次登录都重复看。

C# 简化代码

c
using System; // 引入 Action,用来通知 UI 显示或隐藏引导。
using System.Collections.Generic; // 引入 Dictionary,用来保存引导步骤配置。
public enum GuideCompleteType // 定义引导步骤的完成方式。
{ // GuideCompleteType 枚举开始。
    ClickButton, // 玩家点击指定按钮后完成。
    OpenWindow, // 玩家打开指定界面后完成。
    CustomEvent // 收到指定业务事件后完成。
} // GuideCompleteType 枚举结束。
public sealed class GuideStepConfig // 定义单个引导步骤配置。
{ // GuideStepConfig 类开始。
    public int GuideId; // 引导组 id,例如“装备强化引导”。
    public int StepId; // 当前步骤 id,例如第 1 步。
    public int NextStepId; // 下一步 id,-1 表示引导结束。
    public string TargetKey; // 目标 UI 的唯一名字,例如“Btn_Enhance”。
    public string TipText; // 提示文本,例如“点击这里强化装备”。
    public GuideCompleteType CompleteType; // 当前步骤的完成方式。
    public string CompleteEvent; // 当前步骤等待的事件名。
} // GuideStepConfig 类结束。
public sealed class GuideRuntimeData // 定义玩家引导运行时数据。
{ // GuideRuntimeData 类开始。
    public int CurrentStepId; // 当前正在进行的步骤 id。
    public bool IsCompleted; // 当前引导是否已经全部完成。
} // GuideRuntimeData 类结束。
public sealed class GuideManager // 定义新手引导管理器。
{ // GuideManager 类开始。
    private readonly Dictionary<int, GuideStepConfig> _steps; // 保存所有步骤配置,key 是 stepId。
    private readonly GuideRuntimeData _data = new GuideRuntimeData(); // 保存当前引导进度。
    public event Action<GuideStepConfig> OnShowStep; // 通知 UI 显示某一步引导。
    public event Action OnHideGuide; // 通知 UI 隐藏引导界面。
    public GuideManager(Dictionary<int, GuideStepConfig> steps) // 构造引导管理器。
    { // 构造函数开始。
        _steps = steps; // 保存外部传进来的步骤配置。
    } // 构造函数结束。
    public void StartGuide(int firstStepId) // 开始播放一组引导。
    { // StartGuide 方法开始。
        if (_data.IsCompleted) return; // 如果引导已经完成,就不要重复播放。
        EnterStep(firstStepId); // 进入第一步引导。
    } // StartGuide 方法结束。
    public void OnGameEvent(string eventName) // 外部系统通知引导系统发生了某个事件。
    { // OnGameEvent 方法开始。
        if (_data.IsCompleted) return; // 如果引导已经结束,就不再处理事件。
        if (!_steps.TryGetValue(_data.CurrentStepId, out GuideStepConfig step)) return; // 如果当前步骤不存在,就直接返回。
        if (step.CompleteEvent != eventName) return; // 如果事件名不是当前步骤等待的事件,就不推进。
        GoNext(step); // 当前步骤完成,进入下一步。
    } // OnGameEvent 方法结束。
    private void EnterStep(int stepId) // 进入指定步骤。
    { // EnterStep 方法开始。
        if (!_steps.TryGetValue(stepId, out GuideStepConfig step)) return; // 如果配置不存在,就不显示。
        _data.CurrentStepId = stepId; // 记录当前步骤 id。
        OnShowStep?.Invoke(step); // 通知 UI 显示遮罩、高亮和提示。
    } // EnterStep 方法结束。
    private void GoNext(GuideStepConfig step) // 推进到下一步。
    { // GoNext 方法开始。
        if (step.NextStepId < 0) // 如果下一步 id 小于 0,说明引导结束。
        { // if 开始。
            _data.IsCompleted = true; // 标记引导已经完成,后续应该写入存档。
            OnHideGuide?.Invoke(); // 通知 UI 关闭引导界面。
            return; // 结束方法。
        } // if 结束。
        EnterStep(step.NextStepId); // 进入下一步引导。
    } // GoNext 方法结束。
} // GuideManager 类结束。

实际项目要注意

目标 UI 可能还没创建出来,比如玩家打开背包前,背包按钮对象不存在,所以引导系统要支持“延迟等待目标出现”。

强制引导和弱引导要分开。强制引导会挡住其他点击,只让玩家点指定按钮;弱引导只提示,不强制拦截操作。

引导完成后一定要存档。通常保存 guideIdcurrentStepIdisCompleted,否则玩家重新登录会重复看引导。

面试高分说法

IMPORTANT

新手引导我会做成配置驱动和事件驱动。每一步引导配置目标 UI、提示文本、完成条件和下一步 id;GuideManager 负责播放步骤和监听事件;GuideView 只负责遮罩、高亮、箭头和提示气泡。玩家点击按钮、打开界面、完成操作后,由业务系统发事件给引导系统,引导系统判断是否满足当前步骤完成条件,再推进下一步。这样引导逻辑不会散落在各个 UI 脚本里,后续扩展新引导、跳过、重播、断线恢复都会更容易。

红点系统怎么设计?

红点系统怎么设计?

红点系统本质是一个“提示状态管理系统”:业务系统判断某个功能是否需要提示,红点系统负责把这个状态汇总到父级入口,UI 只负责显示小红点或数字。

red-dot-system-design-csharp

一句话理解

比如背包里有新装备,背包按钮要亮红点;背包按钮亮了,主界面的功能入口也要亮红点。这个“子节点影响父节点”的结构,就是红点系统常用的红点树。

核心设计

红点节点:表示一个功能入口,比如 MainMain/BagMain/Bag/NewEquip

叶子节点:真正由业务决定亮不亮,比如“是否有新装备”“任务是否可领奖”。

父节点:不用自己判断业务,只要有任意子节点亮,父节点就亮。

事件刷新:背包变化、任务完成、邮件收到时通知红点系统刷新,不要每帧扫描。

UI 绑定:按钮只绑定某个红点路径,监听变化后显示或隐藏红点。

为什么不要把红点写在 UI 里

如果每个按钮自己判断红点,会出现几个问题:

  • 背包、任务、邮件、活动逻辑到处散落。
  • 父级红点很难统一汇总。
  • 打开界面、关闭界面时容易漏刷新。
  • 后期排查“为什么这个红点没消失”会很痛苦。

更好的做法是:业务系统只告诉红点系统“我的状态变了”,红点系统统一计算,UI 统一监听。

C# 简化代码

c
using System; // 引入 Action,用来通知 UI 红点变化。
using System.Collections.Generic; // 引入 List 和 Dictionary,用来保存红点树。
public sealed class RedDotNode // 定义红点节点。
{ // RedDotNode 类开始。
    public string Path; // 当前红点节点路径,例如 Main/Bag/NewEquip。
    public bool IsOn; // 当前节点是否亮红点。
    public RedDotNode Parent; // 当前节点的父节点。
    public List<RedDotNode> Children = new List<RedDotNode>(); // 当前节点的子节点列表。
    public Func<bool> Checker; // 叶子节点的检查函数,用来判断是否亮红点。
} // RedDotNode 类结束。
public sealed class RedDotManager // 定义红点管理器。
{ // RedDotManager 类开始。
    private readonly Dictionary<string, RedDotNode> _nodes = new Dictionary<string, RedDotNode>(); // 保存所有红点节点。
    public event Action<string, bool> OnRedDotChanged; // 红点变化事件,参数是路径和是否点亮。
    public void Register(string path, Func<bool> checker = null) // 注册一个红点节点。
    { // Register 方法开始。
        if (_nodes.ContainsKey(path)) return; // 如果节点已经存在,就不重复注册。
        RedDotNode node = new RedDotNode(); // 创建新的红点节点。
        node.Path = path; // 设置节点路径。
        node.Checker = checker; // 设置节点检查函数。
        _nodes.Add(path, node); // 把节点加入字典。
        string parentPath = GetParentPath(path); // 根据路径计算父节点路径。
        if (parentPath != null) // 如果存在父节点路径。
        { // if 开始。
            Register(parentPath); // 确保父节点也被注册。
            node.Parent = _nodes[parentPath]; // 设置当前节点的父节点。
            node.Parent.Children.Add(node); // 把当前节点加入父节点的子节点列表。
        } // if 结束。
    } // Register 方法结束。
    public void Refresh(string path) // 刷新某个红点节点。
    { // Refresh 方法开始。
        if (!_nodes.TryGetValue(path, out RedDotNode node)) return; // 如果节点不存在,就直接返回。
        bool oldValue = node.IsOn; // 记录刷新前的红点状态。
        if (node.Checker != null) node.IsOn = node.Checker(); // 如果是叶子节点,就调用业务检查函数。
        else node.IsOn = HasAnyChildOn(node); // 如果是父节点,就根据子节点汇总状态。
        if (oldValue != node.IsOn) OnRedDotChanged?.Invoke(node.Path, node.IsOn); // 如果状态变化,就通知 UI。
        if (node.Parent != null) Refresh(node.Parent.Path); // 当前节点变化后,继续刷新父节点。
    } // Refresh 方法结束。
    private bool HasAnyChildOn(RedDotNode node) // 判断是否有任意子节点点亮。
    { // HasAnyChildOn 方法开始。
        for (int i = 0; i < node.Children.Count; i++) // 遍历所有子节点。
        { // for 循环开始。
            if (node.Children[i].IsOn) return true; // 只要有一个子节点亮,就返回 true。
        } // for 循环结束。
        return false; // 所有子节点都不亮,就返回 false。
    } // HasAnyChildOn 方法结束。
    private string GetParentPath(string path) // 根据当前路径获取父路径。
    { // GetParentPath 方法开始。
        int index = path.LastIndexOf('/'); // 找到最后一个斜杠的位置。
        if (index < 0) return null; // 如果没有斜杠,说明没有父路径。
        return path.Substring(0, index); // 截取斜杠前面的内容作为父路径。
    } // GetParentPath 方法结束。
} // RedDotManager 类结束。

使用示例

比如背包系统发现玩家获得了新装备:

c
using System; // 引入基础系统命名空间。
public sealed class BagExample // 定义背包示例类。
{ // BagExample 类开始。
    private bool _hasNewEquip; // 记录背包里是否有新装备。
    private readonly RedDotManager _redDotManager; // 保存红点管理器引用。
    public BagExample(RedDotManager redDotManager) // 构造背包示例。
    { // 构造函数开始。
        _redDotManager = redDotManager; // 保存红点管理器引用。
        _redDotManager.Register("Main/Bag/NewEquip", HasNewEquip); // 注册新装备红点检查函数。
    } // 构造函数结束。
    public void AddNewEquip() // 玩家获得新装备。
    { // AddNewEquip 方法开始。
        _hasNewEquip = true; // 标记背包里有新装备。
        _redDotManager.Refresh("Main/Bag/NewEquip"); // 通知红点系统刷新新装备节点。
    } // AddNewEquip 方法结束。
    public void OpenBag() // 玩家打开背包。
    { // OpenBag 方法开始。
        _hasNewEquip = false; // 清除新装备提示标记。
        _redDotManager.Refresh("Main/Bag/NewEquip"); // 通知红点系统刷新新装备节点。
    } // OpenBag 方法结束。
    private bool HasNewEquip() // 检查是否有新装备红点。
    { // HasNewEquip 方法开始。
        return _hasNewEquip; // 返回当前是否有新装备。
    } // HasNewEquip 方法结束。
} // BagExample 类结束。

面试高分说法

NOTE

红点系统我会设计成树结构。叶子节点由具体业务决定,比如背包是否有新装备、任务是否可领奖、邮件是否未读;父节点不直接关心业务,只根据子节点状态汇总。业务数据变化时通过事件通知红点系统刷新相关节点,再向父节点递归更新,最后通知绑定 UI 刷新显示。这样红点逻辑不会散落在各个界面里,排查和扩展都比较清晰,也避免每帧遍历所有系统造成性能浪费。

邮件系统怎么设计?

邮件系统怎么设计?

邮件系统本质是“服务器发消息和奖励,客户端做展示和操作入口”。尤其是带附件的邮件,领取奖励必须以服务器为准,客户端不能自己直接加道具。

mail-system-design-csharp

核心拆法

邮件数据:包含 mailId、标题、正文、发送时间、过期时间、附件、状态。

客户端邮箱管理器:缓存邮件列表,负责标记已读、请求领取、请求删除、通知 UI 刷新。

服务器接口:真正校验邮件是否存在、是否过期、附件是否已经领取,并发放奖励。

邮件 UI:只负责显示列表、正文、附件、红点、领取按钮。

红点逻辑:有未读邮件,或者有未领取附件,就显示邮箱红点。

邮件状态怎么分

常见状态可以分成:

  • Unread:未读。
  • Read:已读,但附件可能还没领。
  • Claimed:附件已领取。
  • Deleted:已删除,客户端不再展示。

C# 简化代码

c
using System; // 引入 Action,用来通知 UI 邮件发生变化。
using System.Collections.Generic; // 引入 List 和 Dictionary,用来保存邮件和附件。
public enum MailState // 定义邮件状态枚举。
{ // MailState 枚举开始。
    Unread, // 未读邮件,需要显示未读红点。
    Read, // 已读邮件,玩家已经打开看过。
    Claimed, // 附件已领取,不能重复领取。
    Deleted // 已删除邮件,客户端列表中不再显示。
} // MailState 枚举结束。
public sealed class MailAttachment // 定义邮件附件数据。
{ // MailAttachment 类开始。
    public int ItemId; // 附件物品 id,例如金币、钻石、道具。
    public int Count; // 附件物品数量。
} // MailAttachment 类结束。
public sealed class MailData // 定义邮件数据。
{ // MailData 类开始。
    public long MailId; // 邮件唯一 id,通常由服务器生成。
    public string Title; // 邮件标题。
    public string Content; // 邮件正文。
    public long ExpireTime; // 邮件过期时间,可以用时间戳表示。
    public MailState State; // 当前邮件状态。
    public List<MailAttachment> Attachments = new List<MailAttachment>(); // 当前邮件携带的附件列表。
    public bool HasAttachment => Attachments.Count > 0; // 判断这封邮件是否有附件。
    public bool CanClaim => HasAttachment && State != MailState.Claimed && State != MailState.Deleted; // 判断附件是否还能领取。
} // MailData 类结束。
public interface IMailServerApi // 定义邮件服务器接口。
{ // IMailServerApi 接口开始。
    bool MarkRead(long mailId); // 请求服务器把邮件标记为已读。
    bool ClaimAttachment(long mailId); // 请求服务器领取邮件附件。
    bool DeleteMail(long mailId); // 请求服务器删除邮件。
} // IMailServerApi 接口结束。
public sealed class MailManager // 定义客户端邮件管理器。
{ // MailManager 类开始。
    private readonly IMailServerApi _serverApi; // 保存服务器接口引用。
    private readonly Dictionary<long, MailData> _mails = new Dictionary<long, MailData>(); // 保存客户端缓存的邮件。
    public event Action OnMailChanged; // 邮件变化事件,UI 可以监听它刷新列表和红点。
    public MailManager(IMailServerApi serverApi) // 构造邮件管理器。
    { // 构造函数开始。
        _serverApi = serverApi; // 保存服务器接口。
    } // 构造函数结束。
    public void LoadMails(List<MailData> mailsFromServer) // 从服务器加载邮件列表。
    { // LoadMails 方法开始。
        _mails.Clear(); // 清空旧邮件缓存。
        for (int i = 0; i < mailsFromServer.Count; i++) // 遍历服务器返回的邮件列表。
        { // for 循环开始。
            MailData mail = mailsFromServer[i]; // 取出一封邮件。
            _mails[mail.MailId] = mail; // 按 mailId 缓存邮件。
        } // for 循环结束。
        OnMailChanged?.Invoke(); // 通知 UI 邮件列表发生变化。
    } // LoadMails 方法结束。
    public bool ReadMail(long mailId) // 打开邮件并标记已读。
    { // ReadMail 方法开始。
        if (!_mails.TryGetValue(mailId, out MailData mail)) return false; // 如果本地没有这封邮件,就返回失败。
        if (mail.State != MailState.Unread) return true; // 如果邮件不是未读,就不需要重复标记。
        if (!_serverApi.MarkRead(mailId)) return false; // 请求服务器标记已读,失败就不改本地状态。
        mail.State = MailState.Read; // 服务器成功后,本地状态改成已读。
        OnMailChanged?.Invoke(); // 通知 UI 刷新邮件状态。
        return true; // 返回成功。
    } // ReadMail 方法结束。
    public bool ClaimAttachment(long mailId) // 领取邮件附件。
    { // ClaimAttachment 方法开始。
        if (!_mails.TryGetValue(mailId, out MailData mail)) return false; // 如果邮件不存在,就返回失败。
        if (!mail.CanClaim) return false; // 如果没有附件或已经领取,就返回失败。
        if (!_serverApi.ClaimAttachment(mailId)) return false; // 请求服务器领取附件,失败就不改本地状态。
        mail.State = MailState.Claimed; // 服务器确认发奖后,本地状态改成已领取。
        OnMailChanged?.Invoke(); // 通知 UI 刷新领取按钮和红点。
        return true; // 返回领取成功。
    } // ClaimAttachment 方法结束。
    public bool DeleteMail(long mailId) // 删除邮件。
    { // DeleteMail 方法开始。
        if (!_mails.TryGetValue(mailId, out MailData mail)) return false; // 如果邮件不存在,就返回失败。
        if (mail.CanClaim) return false; // 如果附件还没领,通常不允许直接删除。
        if (!_serverApi.DeleteMail(mailId)) return false; // 请求服务器删除邮件,失败就不改本地。
        mail.State = MailState.Deleted; // 本地标记为已删除。
        _mails.Remove(mailId); // 从本地列表移除邮件。
        OnMailChanged?.Invoke(); // 通知 UI 刷新邮件列表。
        return true; // 返回删除成功。
    } // DeleteMail 方法结束。
    public bool HasRedDot() // 判断邮箱入口是否显示红点。
    { // HasRedDot 方法开始。
        foreach (MailData mail in _mails.Values) // 遍历所有本地邮件。
        { // foreach 循环开始。
            if (mail.State == MailState.Unread) return true; // 只要有未读邮件,就显示红点。
            if (mail.CanClaim) return true; // 只要有可领取附件,也显示红点。
        } // foreach 循环结束。
        return false; // 没有未读和可领奖邮件,就不显示红点。
    } // HasRedDot 方法结束。
} // MailManager 类结束。

面试高分说法

TIP

邮件系统我会让服务器做权威数据,客户端只做缓存和展示。邮件列表支持分页拉取,邮件状态包括未读、已读、已领取、已删除。玩家读邮件、领附件、删邮件时,客户端先发请求给服务器,服务器校验邮件是否存在、是否过期、附件是否已领取,确认成功后客户端再更新本地状态并刷新 UI。红点根据未读邮件和未领取附件计算。这样可以防止重复领取附件,也能保证邮件数据和背包奖励一致。

排行榜怎么设计?

排行榜怎么设计?

排行榜系统本质是“服务器保存权威成绩,按规则排序,客户端分页展示”。尤其是联网游戏里,排行榜不能让客户端自己算最终排名,否则很容易作弊和数据不一致。

leaderboard-system-design-csharp

核心拆法

排行榜数据:玩家 id、昵称、头像、分数、排名、更新时间。

排序规则:比如战力越高排名越前;如果同分,可以按达成时间更早、等级更高、玩家 id 更小来决定顺序。

服务器逻辑:校验分数、更新成绩、排序、分页返回、结算奖励。

客户端管理器:请求榜单、缓存当前页、请求自己的排名、通知 UI 刷新。

排行榜 UI:显示名次、头像、昵称、分数、奖励、刷新倒计时。

为什么排行榜通常放服务器

因为排行榜涉及公平性。比如玩家提交了一个“999999999 战力”,客户端不能直接相信,服务器要校验这个分数是否由真实装备、等级、属性计算出来。

客户端适合做展示,服务器负责最终权威数据。

C# 简化代码

c
using System; // 引入 Action,用来通知 UI 排行榜变化。
using System.Collections.Generic; // 引入 List,用来保存榜单条目。
public sealed class RankEntry // 定义排行榜中的一条记录。
{ // RankEntry 类开始。
    public long PlayerId; // 玩家唯一 id。
    public string PlayerName; // 玩家昵称。
    public int Rank; // 玩家当前排名。
    public int Score; // 玩家分数,例如战力或积分。
    public long UpdateTime; // 分数更新时间,用来处理同分排序。
} // RankEntry 类结束。
public sealed class RankPage // 定义排行榜分页结果。
{ // RankPage 类开始。
    public int PageIndex; // 当前页下标。
    public int PageSize; // 每页显示多少条。
    public List<RankEntry> Entries = new List<RankEntry>(); // 当前页的排行榜条目。
    public RankEntry MyEntry; // 玩家自己的排名信息。
} // RankPage 类结束。
public interface ILeaderboardServerApi // 定义排行榜服务器接口。
{ // ILeaderboardServerApi 接口开始。
    RankPage RequestPage(int pageIndex, int pageSize); // 请求某一页排行榜数据。
    RankEntry RequestMyRank(long playerId); // 请求自己的排名数据。
} // ILeaderboardServerApi 接口结束。
public sealed class LeaderboardManager // 定义客户端排行榜管理器。
{ // LeaderboardManager 类开始。
    private readonly ILeaderboardServerApi _serverApi; // 保存服务器接口引用。
    private RankPage _currentPage; // 保存当前客户端展示的排行榜页。
    public event Action OnLeaderboardChanged; // 排行榜变化事件,UI 监听后刷新界面。
    public LeaderboardManager(ILeaderboardServerApi serverApi) // 构造排行榜管理器。
    { // 构造函数开始。
        _serverApi = serverApi; // 保存服务器接口引用。
    } // 构造函数结束。
    public void LoadPage(int pageIndex, int pageSize) // 加载某一页排行榜。
    { // LoadPage 方法开始。
        _currentPage = _serverApi.RequestPage(pageIndex, pageSize); // 向服务器请求分页榜单。
        OnLeaderboardChanged?.Invoke(); // 通知 UI 刷新排行榜列表。
    } // LoadPage 方法结束。
    public RankPage GetCurrentPage() // 获取当前缓存的排行榜页。
    { // GetCurrentPage 方法开始。
        return _currentPage; // 返回当前排行榜页数据。
    } // GetCurrentPage 方法结束。
    public void RefreshMyRank(long playerId) // 刷新自己的排名。
    { // RefreshMyRank 方法开始。
        if (_currentPage == null) return; // 如果当前还没有榜单页,就直接返回。
        _currentPage.MyEntry = _serverApi.RequestMyRank(playerId); // 单独请求自己的排名。
        OnLeaderboardChanged?.Invoke(); // 通知 UI 刷新自己的排名区域。
    } // RefreshMyRank 方法结束。
} // LeaderboardManager 类结束。
public sealed class LeaderboardSorter // 定义服务器侧排序工具示例。
{ // LeaderboardSorter 类开始。
    public static void Sort(List<RankEntry> entries) // 对排行榜条目进行排序。
    { // Sort 方法开始。
        entries.Sort(CompareRankEntry); // 使用自定义比较函数排序。
        for (int i = 0; i < entries.Count; i++) // 遍历排序后的所有玩家。
        { // for 循环开始。
            entries[i].Rank = i + 1; // 给每个玩家写入排名,数组下标加一就是名次。
        } // for 循环结束。
    } // Sort 方法结束。
    private static int CompareRankEntry(RankEntry a, RankEntry b) // 比较两个排行榜条目的先后顺序。
    { // CompareRankEntry 方法开始。
        int scoreCompare = b.Score.CompareTo(a.Score); // 分数高的排前面,所以用 b 和 a 比。
        if (scoreCompare != 0) return scoreCompare; // 如果分数不同,直接返回分数比较结果。
        int timeCompare = a.UpdateTime.CompareTo(b.UpdateTime); // 分数相同,更新时间更早的排前面。
        if (timeCompare != 0) return timeCompare; // 如果时间不同,返回时间比较结果。
        return a.PlayerId.CompareTo(b.PlayerId); // 如果还相同,就用玩家 id 保证排序稳定。
    } // CompareRankEntry 方法结束。
} // LeaderboardSorter 类结束。

面试高分说法

TIP

排行榜我会设计成服务器权威、客户端展示。玩家分数由服务器校验后写入排行榜,服务器按照明确规则排序,比如分数高优先,同分按达成时间或玩家 id 排序。客户端请求榜单时用分页拉取,只展示当前页、Top N 和自己的排名,不一次拉全量数据。排行榜服务端可以做定时缓存,避免每次请求都实时全量排序。赛季榜或活动榜的奖励也应该由服务器在结算时统一发放,防止客户端伪造排名或重复领奖。

设置面板如何保存配置?

设置面板如何保存配置?

设置面板不要理解成“点一下按钮就改 UI”,它应该是:UI 先改临时配置,玩家点“应用/确定”后,再写入真正配置,让系统生效,最后保存到本地或服务器。

settings-panel-save-config-csharp

核心思路

设置一般分三份:

  • 默认配置:第一次进游戏、读取失败、版本升级时使用。
  • 当前配置:游戏真正正在使用的配置。
  • 临时配置:玩家在设置面板里拖动滑杆、勾选开关时先改这里。

这样玩家点“取消”时,可以直接丢弃临时配置;点“应用”时,才把临时配置复制到当前配置,并保存。

保存到哪里

PlayerPrefs:适合简单设置,比如音量、画质等级、是否开启震动、是否全屏。

JSON 文件:适合复杂配置,比如按键映射、多个画质参数、自定义布局。

服务器:适合账号级设置,比如隐私设置、跨设备同步设置。

普通 Unity 客户端面试里,说 PlayerPrefs 就够;如果想显得更专业,可以补一句:复杂配置用 JSON,本地只存普通偏好,敏感数据不要存 PlayerPrefs。

C# 示例

c
using System; // 引入基础命名空间,用来使用 Math 等工具。
using UnityEngine; // 引入 UnityEngine,用来使用 PlayerPrefs 和 Mathf。
[Serializable] // 标记这个类可以被序列化,方便以后扩展成 JSON 保存。
public sealed class GameSettings // 定义游戏设置数据类。
{ // GameSettings 类开始。
    public float MasterVolume = 1.0f; // 主音量,范围通常是 0 到 1。
    public float MusicVolume = 1.0f; // 音乐音量,范围通常是 0 到 1。
    public float SfxVolume = 1.0f; // 音效音量,范围通常是 0 到 1。
    public int QualityLevel = 2; // 画质等级,例如 0 低、1 中、2 高。
    public bool IsVibrationOn = true; // 是否开启震动。
    public GameSettings Clone() // 克隆一份设置,避免临时修改直接影响当前配置。
    { // Clone 方法开始。
        return new GameSettings // 创建并返回一份新的设置对象。
        { // 对象初始化开始。
            MasterVolume = MasterVolume, // 复制主音量。
            MusicVolume = MusicVolume, // 复制音乐音量。
            SfxVolume = SfxVolume, // 复制音效音量。
            QualityLevel = QualityLevel, // 复制画质等级。
            IsVibrationOn = IsVibrationOn // 复制震动开关。
        }; // 对象初始化结束。
    } // Clone 方法结束。
} // GameSettings 类结束。
public sealed class SettingsManager // 定义设置管理器。
{ // SettingsManager 类开始。
    private const string MasterVolumeKey = "settings_master_volume"; // 定义主音量保存键。
    private const string MusicVolumeKey = "settings_music_volume"; // 定义音乐音量保存键。
    private const string SfxVolumeKey = "settings_sfx_volume"; // 定义音效音量保存键。
    private const string QualityLevelKey = "settings_quality_level"; // 定义画质等级保存键。
    private const string VibrationKey = "settings_vibration"; // 定义震动开关保存键。
    public GameSettings Current { get; private set; } = new GameSettings(); // 当前真正生效的配置。
    public event Action<GameSettings> OnSettingsApplied; // 设置应用事件,音频、画质、UI 可以监听它。
    public void Load() // 从本地读取设置。
    { // Load 方法开始。
        Current.MasterVolume = PlayerPrefs.GetFloat(MasterVolumeKey, 1.0f); // 读取主音量,没存过就用默认值 1。
        Current.MusicVolume = PlayerPrefs.GetFloat(MusicVolumeKey, 1.0f); // 读取音乐音量,没存过就用默认值 1。
        Current.SfxVolume = PlayerPrefs.GetFloat(SfxVolumeKey, 1.0f); // 读取音效音量,没存过就用默认值 1。
        Current.QualityLevel = PlayerPrefs.GetInt(QualityLevelKey, 2); // 读取画质等级,没存过就用默认值 2。
        Current.IsVibrationOn = PlayerPrefs.GetInt(VibrationKey, 1) == 1; // 读取震动开关,用 1 表示开启。
        ApplyToGame(Current); // 把读取到的配置应用到游戏系统。
    } // Load 方法结束。
    public GameSettings CreateDraft() // 创建设置面板使用的临时配置。
    { // CreateDraft 方法开始。
        return Current.Clone(); // 返回当前配置的一份拷贝。
    } // CreateDraft 方法结束。
    public void ApplyAndSave(GameSettings draft) // 应用并保存玩家在面板中修改后的配置。
    { // ApplyAndSave 方法开始。
        ClampSettings(draft); // 先修正非法数值,避免音量超过范围。
        Current = draft.Clone(); // 把临时配置复制成当前正式配置。
        ApplyToGame(Current); // 让音频、画质、震动等系统立即生效。
        Save(Current); // 把当前配置保存到本地。
        OnSettingsApplied?.Invoke(Current); // 通知外部系统设置已经变化。
    } // ApplyAndSave 方法结束。
    private void Save(GameSettings settings) // 保存设置到 PlayerPrefs。
    { // Save 方法开始。
        PlayerPrefs.SetFloat(MasterVolumeKey, settings.MasterVolume); // 保存主音量。
        PlayerPrefs.SetFloat(MusicVolumeKey, settings.MusicVolume); // 保存音乐音量。
        PlayerPrefs.SetFloat(SfxVolumeKey, settings.SfxVolume); // 保存音效音量。
        PlayerPrefs.SetInt(QualityLevelKey, settings.QualityLevel); // 保存画质等级。
        PlayerPrefs.SetInt(VibrationKey, settings.IsVibrationOn ? 1 : 0); // 保存震动开关,true 存 1,false 存 0。
        PlayerPrefs.Save(); // 强制写入磁盘,适合点击确定或退出设置面板时调用。
    } // Save 方法结束。
    private void ClampSettings(GameSettings settings) // 修正设置数值范围。
    { // ClampSettings 方法开始。
        settings.MasterVolume = Mathf.Clamp01(settings.MasterVolume); // 把主音量限制在 0 到 1。
        settings.MusicVolume = Mathf.Clamp01(settings.MusicVolume); // 把音乐音量限制在 0 到 1。
        settings.SfxVolume = Mathf.Clamp01(settings.SfxVolume); // 把音效音量限制在 0 到 1。
        settings.QualityLevel = Mathf.Clamp(settings.QualityLevel, 0, 5); // 把画质等级限制在合理范围。
    } // ClampSettings 方法结束。
    private void ApplyToGame(GameSettings settings) // 把设置应用到游戏系统。
    { // ApplyToGame 方法开始。
        AudioListener.volume = settings.MasterVolume; // 示例:把主音量应用到 Unity 全局音量。
        QualitySettings.SetQualityLevel(settings.QualityLevel); // 示例:把画质等级应用到 Unity 画质系统。
    } // ApplyToGame 方法结束。
} // SettingsManager 类结束。

面试高分说法

TIP

设置面板我会分成临时配置和正式配置。玩家在 UI 上拖动音量、切换画质时,只修改临时配置;点“应用”后再校验数值范围,把临时配置复制到正式配置,并通知音频、画质、语言等系统刷新,最后保存到 PlayerPrefs 或 JSON。简单偏好用 PlayerPrefs,复杂配置用 JSON,账号级配置可以同步服务器。这样可以支持取消、恢复默认、版本升级默认值,也避免每次拖动滑杆都频繁写磁盘。

多语言系统怎么做?

多语言系统怎么做?

多语言系统的核心思想是:代码里不要写死中文,而是写一个 key,运行时根据当前语言去表里查真正显示的文本。

localization-system-design-csharp

一句话理解

比如按钮上不要直接写 "开始游戏",而是写 key:"ui_start"

中文环境查出来是:开始游戏。 英文环境查出来是:Start Game。 日文环境查出来是:ゲーム開始

核心模块

文本表:保存 key 和不同语言的翻译,比如 ui_start 对应中文、英文、日文。

语言管理器:保存当前语言,比如 zh-CNen-USja-JP

查询接口:通过 key 获取当前语言文本,比如 GetText("ui_start")

UI 绑定组件:每个 Text 组件保存自己的 key,语言切换时自动刷新。

本地保存:玩家选择语言后保存到 PlayerPrefs 或设置文件。

兜底机制:如果当前语言没有某个 key,就回退到默认语言,避免显示空白。

C# 简化代码

c
using System; // 引入 Action,用来通知 UI 语言发生变化。
using System.Collections.Generic; // 引入 Dictionary,用来保存多语言文本表。
using UnityEngine; // 引入 UnityEngine,用来使用 PlayerPrefs。
public enum GameLanguage // 定义游戏支持的语言。
{ // GameLanguage 枚举开始。
    Chinese, // 中文。
    English, // 英文。
    Japanese // 日文。
} // GameLanguage 枚举结束。
public sealed class LocalizationManager // 定义多语言管理器。
{ // LocalizationManager 类开始。
    private const string LanguageKey = "game_language"; // 定义保存当前语言的 PlayerPrefs 键。
    private readonly Dictionary<GameLanguage, Dictionary<string, string>> _tables = new Dictionary<GameLanguage, Dictionary<string, string>>(); // 保存所有语言表。
    public GameLanguage CurrentLanguage { get; private set; } = GameLanguage.Chinese; // 当前语言,默认中文。
    public event Action OnLanguageChanged; // 语言变化事件,UI 监听后刷新文本。
    public void Init() // 初始化多语言系统。
    { // Init 方法开始。
        LoadBuiltInTables(); // 加载内置语言表。
        int savedLanguage = PlayerPrefs.GetInt(LanguageKey, (int)GameLanguage.Chinese); // 从本地读取玩家上次选择的语言。
        CurrentLanguage = (GameLanguage)savedLanguage; // 设置当前语言。
    } // Init 方法结束。
    public string GetText(string key) // 根据 key 获取当前语言文本。
    { // GetText 方法开始。
        if (TryGetText(CurrentLanguage, key, out string text)) return text; // 优先查当前语言文本。
        if (TryGetText(GameLanguage.Chinese, key, out string fallbackText)) return fallbackText; // 当前语言缺失时回退到中文。
        return "#" + key; // 如果所有语言都找不到,就显示 key,方便开发时发现问题。
    } // GetText 方法结束。
    public void SetLanguage(GameLanguage language) // 切换当前语言。
    { // SetLanguage 方法开始。
        if (CurrentLanguage == language) return; // 如果语言没有变化,就不用刷新。
        CurrentLanguage = language; // 更新当前语言。
        PlayerPrefs.SetInt(LanguageKey, (int)language); // 保存玩家选择的语言。
        PlayerPrefs.Save(); // 立即写入本地配置。
        OnLanguageChanged?.Invoke(); // 通知所有 UI 文本刷新。
    } // SetLanguage 方法结束。
    private bool TryGetText(GameLanguage language, string key, out string text) // 尝试从指定语言表中取文本。
    { // TryGetText 方法开始。
        text = null; // 先把输出文本设置为空。
        if (!_tables.TryGetValue(language, out Dictionary<string, string> table)) return false; // 如果没有这个语言表,就返回失败。
        return table.TryGetValue(key, out text); // 从语言表中查 key,并返回是否查到。
    } // TryGetText 方法结束。
    private void LoadBuiltInTables() // 加载内置语言表,真实项目可从 CSV、JSON、Excel 或 Unity Localization 加载。
    { // LoadBuiltInTables 方法开始。
        _tables[GameLanguage.Chinese] = new Dictionary<string, string>(); // 创建中文表。
        _tables[GameLanguage.Chinese]["ui_start"] = "开始游戏"; // 写入中文开始按钮文本。
        _tables[GameLanguage.Chinese]["ui_shop"] = "商店"; // 写入中文商店文本。
        _tables[GameLanguage.Chinese]["ui_setting"] = "设置"; // 写入中文设置文本。
        _tables[GameLanguage.English] = new Dictionary<string, string>(); // 创建英文表。
        _tables[GameLanguage.English]["ui_start"] = "Start Game"; // 写入英文开始按钮文本。
        _tables[GameLanguage.English]["ui_shop"] = "Shop"; // 写入英文商店文本。
        _tables[GameLanguage.English]["ui_setting"] = "Settings"; // 写入英文设置文本。
        _tables[GameLanguage.Japanese] = new Dictionary<string, string>(); // 创建日文表。
        _tables[GameLanguage.Japanese]["ui_start"] = "ゲーム開始"; // 写入日文开始按钮文本。
        _tables[GameLanguage.Japanese]["ui_shop"] = "ショップ"; // 写入日文商店文本。
        _tables[GameLanguage.Japanese]["ui_setting"] = "設定"; // 写入日文设置文本。
    } // LoadBuiltInTables 方法结束。
} // LocalizationManager 类结束。

UI 文本绑定示例

c
using UnityEngine; // 引入 UnityEngine,用来继承 MonoBehaviour。
using UnityEngine.UI; // 引入 UnityEngine.UI,用来操作 Text 组件。
public sealed class LocalizedText : MonoBehaviour // 定义一个挂在 UI 文本上的多语言组件。
{ // LocalizedText 类开始。
    [SerializeField] private string _key; // 在 Inspector 里填写文本 key,例如 ui_start。
    [SerializeField] private Text _text; // 绑定需要显示文本的 UI Text 组件。
    private LocalizationManager _localization; // 保存多语言管理器引用。
    public void Init(LocalizationManager localization) // 初始化绑定。
    { // Init 方法开始。
        _localization = localization; // 保存多语言管理器。
        _localization.OnLanguageChanged += Refresh; // 监听语言变化事件。
        Refresh(); // 初始化时立即刷新一次文本。
    } // Init 方法结束。
    private void OnDestroy() // 当前 UI 被销毁时调用。
    { // OnDestroy 方法开始。
        if (_localization != null) _localization.OnLanguageChanged -= Refresh; // 取消订阅,避免事件引用导致内存泄漏。
    } // OnDestroy 方法结束。
    private void Refresh() // 刷新当前 UI 文本。
    { // Refresh 方法开始。
        if (_text == null) return; // 如果没有绑定 Text 组件,就直接返回。
        if (_localization == null) return; // 如果没有初始化多语言管理器,就直接返回。
        _text.text = _localization.GetText(_key); // 根据 key 获取当前语言文本并显示。
    } // Refresh 方法结束。
} // LocalizedText 类结束。

实际项目要注意

多语言不只是文字翻译。英文可能比中文长很多,按钮宽度和布局要能适配;中文、日文、韩文需要字体字库支持;有文字的图片、语音、剧情文本、日期格式、货币格式也可能需要本地化。

面试高分说法

IMPORTANT

多语言系统我会做成 key-value 查表。代码和 UI 不直接写死具体语言文本,而是保存文本 key;运行时根据当前语言从语言表里取文本。切换语言时,LocalizationManager 更新当前语言并发出事件,所有绑定了 LocalizedText 的 UI 自动刷新。语言表可以来自 CSV、JSON、Excel、ScriptableObject,也可以用 Unity Localization Package。系统还要支持缺失 key 的兜底语言、字体切换、超长文本适配、图片和语音资源本地化。

文章评价

读完这篇,留下你的看法

暂无审核通过的评价。

登录账号后才能评价。

本站访客数0总站访问量0本页访问量0