当前位置: 首页 > news >正文

第七章——流程逻辑

缓存文件

 Vscode设置

下载扩展如下

Unity编辑器中的设置

将ProtoBuff文件夹拖拽进入Unity中后

在ProjectSetting中如下设置

添加模块资源——L2Cpp(并在ProjectSetting中设定好)

文件夹中的设置

GenAllC中设置如下:

一旦运行该GenAllC就会使Proto文件夹中更新GameSaveProto脚本

@echo offset "PROTOC_EXE=%cd%\tool\protoc.exe"
set "WORK_DIR=%cd%\ProtoFile":: 输出路径:注意路径中包含空格和括号,必须用引号保护
set "CS_OUT_PATH=%cd%\..\..\My project (1) - L4\Assets\Script\Proto":: 确保输出目录存在(关键!)
if not exist "%CS_OUT_PATH%" mkdir "%CS_OUT_PATH%":: 遍历所有 .proto 文件并生成 C# 代码
for /f "delims=" %%i in ('dir /b "%WORK_DIR%\*.proto"') do (echo gen protoFile/%%i..."%PROTOC_EXE%" --proto_path="%WORK_DIR%" --csharp_out="%CS_OUT_PATH%" "%WORK_DIR%\%%i"
)echo finish...
pause

批处理(.bat)脚本的作用是:使用 Google Protocol Buffers(protobuf)的编译器 protoc.exe,将指定目录下的 .proto 文件批量编译成 C# 代码(.cs 文件),并输出到 Unity 项目的脚本目录中

该段代码理解:

@echo off
  • 作用:关闭命令回显(即运行时不显示每条命令本身,只显示输出内容),让界面更干净。

set "PROTOC_EXE=%cd%\tool\protoc.exe"
  • 作用:定义一个变量 PROTOC_EXE,指向当前目录下 tool 文件夹中的 protoc.exe
  • %cd% 表示当前批处理文件所在的完整路径
  • 例如:如果脚本在 E:\unity practice\Tools\protocol-buffers\gen_proto.bat,那么 %cd% 就是 E:\unity practice\Tools\protocol-buffers

set "WORK_DIR=%cd%\ProtoFile"
  • 作用:定义 .proto 源文件所在的目录。
  • 即所有要编译的 .proto 文件都放在当前目录下的 ProtoFile 文件夹中。

: 这里的路径要改变一下与Proto文件夹相对应
set "CS_OUT_PATH=%cd%\..\..\My project (1) - L4\Assets\Script\Proto"
  • 作用:设置 C# 生成文件的输出目录。
  • %cd%\..\..\ 表示向上回退两级目录。
    • 例如:当前在 E:\unity practice\Tools\protocol-buffers
    • 那么 %cd%\..\.. 就是 E:\unity practice
    • 最终路径:E:\unity practice\My project (1) - L4\Assets\Script\Proto
  • 这个路径通常是你的 Unity 项目中的脚本目录,方便生成的协议类被 Unity 使用。

if not exist "%CS_OUT_PATH%" mkdir "%CS_OUT_PATH%"
  • 作用:本意是“如果输出目录不存在,就创建它”。
  • 但被注释掉了(前面有 ::),所以当前不会创建目录
    • 问题:如果该目录不存在,protoc 会报错(正如你之前遇到的 "No such file or directory"
for /f "delims=" %%i in ('dir /b "ProtoFile\*.proto"') do (
  • 作用:遍历 ProtoFile 文件夹中所有 .proto 文件。
  • dir /b "ProtoFile\*.proto":以简洁格式(只有文件名)列出所有 .proto 文件。
  • for /f "delims=" %%i:逐个读取每个文件名(delims= 表示不按空格/制表符分割,保留完整文件名,防止文件名含空格出错)。
  • %%i 是循环变量,代表当前 .proto 文件名(如 GameSaveProto.proto)。

    echo gen protoFile/%%i...
  • 作用:在控制台打印当前正在处理的文件,用于提示和调试

"%PROTOC_EXE%" --proto_path="%WORK_DIR%" --csharp_out="%CS_OUT_PATH%" "%WORK_DIR%\%%i"
  • 作用:调用 protoc.exe 编译单个 .proto 文件。
    • "%PROTOC_EXE%":带引号的可执行文件路径(防止空格问题)。
    • --proto_path=...:指定 .proto 文件的搜索根目录(import 时会用到)。
    • --csharp_out=...:指定生成 C# 代码的输出目录。
    • "%WORK_DIR%\%%i":当前要编译的具体 .proto 文件的完整路径。

沙盒目录工具

        打开沙盒目录用于设置存档目录。

创建了 Save 子目录,用来存放游戏存档文件。

存档的原理机制


Proto文件详解

        这段代码是一个 Protocol Buffers(简称 Protobuf).proto 文件,文件名为 GameSaveProto.proto,它定义了一套用于序列化和反序列化数据的结构。

        这类文件常用于游戏开发、网络通信、跨平台数据存储等场景中,以实现高效、轻量级的数据交换

syntax = "proto3";option csharp_namespace = "Google.Protobuf.Gs";message SaveInfoMain {string scene = 1; // 所处的场景SavePlayerInfo player = 2; // 主角的数据map<int32, SaveGrildInfo> grilds = 3; // 背包信息
}// 关卡数据:可能包含多个AI、多个宝箱、关卡剩余的时间、多个计时器
message SaveLevelInfo {map<string, SavePlayerInfo> ai = 1; // AImap<string, SaveChestInfo> chest = 2; // 宝箱float time = 3; // 关卡剩余的时间map<int32, SaveTimerInfo> timer = 4; // 计时器的状态/剩余时间
}message SavePlayerInfo {int32 state = 1; // 状态int32 hp = 2; // 当前的血量repeated float pos = 3; // 当前的位置repeated float rot = 4; // 当前的角度bool active =5;//是否激活玩家
}message SaveGrildInfo {int32 id = 1; // 格子IDint32 prop_id = 2; // 道具IDint32 count = 3; // 剩余的数量
}message SaveChestInfo {int32 state = 1; // 状态
}message SaveTimerInfo {int32 state = 1; // 状态int32 time = 2; // 已经开始了多长时间
}

message 是什么类型

  • message 是 Protobuf 中自定义数据结构的基本单位。
  • 它类似于 C# 中的 class 或 struct,Java 中的 class,Python 中的 dataclass

map<key, value> 是什么类型?

  • map<K, V> 是 Protobuf 提供的键值对映射类型,等价于:
    • C# 中的 Dictionary<K, V>

核心消息结构解析

1. SaveInfoMain —— 整体存档主结构

message SaveInfoMain {string scene = 1; // 所处的场景SavePlayerInfo player = 2; // 主角的数据map<int32, SaveGrildInfo> grilds = 3; // 背包信息
}
  • 表示一个完整的“游戏存档”数据。
  • 包含三个关键部分:
    • 当前所在场景名称(如 "MainStage"
    • 玩家角色的状态(血量、位置等)
    • 背包里的物品列表(用 map<int32, ...> 实现键值对,key 是格子 ID)

2. SaveLevelInfo —— 关卡内部状态

message SaveLevelInfo {map<string, SavePlayerInfo> ai = 1; // AImap<string, SaveChestInfo> chest = 2; // 宝箱float time = 3; // 关卡剩余的时间map<int32, SaveTimerInfo> timer = 4; // 计时器的状态/剩余时间
}
  • 描述某个关卡内的动态对象状态。
  • 示例用途:
    • 记录每个 AI 角色的位置、血量
    • 宝箱是否已被打开
    • 倒计时还剩多久
  • 这些信息通常不会保存到全局存档,而是作为 SaveInfoMain 的一部分或单独保存。

3. SavePlayerInfo —— 玩家/角色基础信息

message SavePlayerInfo {int32 state = 1; // 状态int32 hp = 2; // 当前的血量repeated float pos = 3; // 当前的位置 (x, y, z)repeated float rot = 4; // 当前的角度 (pitch, yaw, roll 或 Euler angles)bool active = 5; // 是否激活玩家
}
  • 用于表示任意一个角色(包括主角和 AI)的状态。
  • pos 和 rot 使用 repeated float 来存储三维向量,例如:
    pos: [10.0, 5.0, 0.0]
    rot: [0.0, 90.0, 0.0]
  • active 控制角色是否处于活动状态(如死亡后不活跃)

4. SaveGrildInfo —— 背包格子信息

message SaveGrildInfo {int32 id = 1; // 格子IDint32 prop_id = 2; // 道具IDint32 count = 3; // 剩余的数量
}
  • 表示背包中的单个格子。
  • 例如:格子 ID=100,放的是道具 ID=201(剑),数量=3。

5. SaveChestInfo —— 宝箱状态

message SaveChestInfo {int32 state = 1; // 状态(0=未开,1=已开)
}
  • 很简单,只记录宝箱是否被开启过。
  • 可扩展为更多字段(如奖励内容、是否刷新等)

6. SaveTimerInfo —— 计时器状态

message SaveTimerInfo {int32 state = 1; // 状态(运行中?暂停?结束?)int32 time = 2; // 已经开始了多长时间(秒)
}
  • 用于保存游戏中各种倒计时器的状态。
  • 例如:限时任务、技能冷却、机关触发时间等。

Proto文件修改流程

1、修改Proto文件

        主要先想好哪些数据内容需要被存档。

message SavePlayerInfo {int32 state = 1; // 状态int32 hp = 2; // 当前的血量repeated float pos = 3; // 当前的位置repeated float rot = 4; // 当前的角度bool active =5;//是否激活玩家int32 atkTargetGlobalId = 6;//全局ID
}

这里的数字代表的序列——而不是默认常数

2、进行更新

3、在对应类型中进行赋值

public SavePlayerInfo(SavePlayerInfo other) : this() {state_ = other.state_;hp_ = other.hp_;pos_ = other.pos_.Clone();rot_ = other.rot_.Clone();active_ = other.active_;atkTargetGlobalId_= other.atkTargetGlobalId_;//符号和顺序要一一对应_unknownFields = pb::UnknownFieldSet.Clone(other._unknownFields);
}

当加入新参数atkTargetGlobalId_时,会自动生成对应获取方法如下:

public int AtkTargetGlobalId {get { return atkTargetGlobalId_; }set {atkTargetGlobalId_ = value;}
}

4、调用该参数

    if (Player.AtkTargetGlobalId != -1)  // 注意:A 大写,无下划线!{FSM target = UnitManager.Instance.GetUnitByGlobalId(Player.AtkTargetGlobalId);if (target != null){currentTargetInstanceId = target.instance_id; // 更新为当前有效的 instance_idDebug.Log($"恢复攻击目标: {target.name}");}else{currentTargetInstanceId = -1; // 目标不存在,清空Debug.Log("存档中的攻击目标未找到,已清空");}}else{currentTargetInstanceId = -1;}ToNext(Player.State);}

这里不是直接调用的该参数,而是获取该参数的方法!

代码流程——存档和读档机制的运行

模块职责
C# 脚本 (GameSave.cs)控制存档/读档流程,收集游戏对象状态,调用 Protobuf 工具进行序列化/反序列化,读写文件
Protobuf .proto 文件定义存档数据结构,生成 C# 类(如 SaveInfoMainSavePlayerInfo 等),用于数据传输和持久化

存档和读档要点前瞻

反序列化 → 直接使用数据 → 调用 Recover

  1. 存档时(序列化)

    • 从游戏对象(如玩家、NPC)提取状态 → 填入 Protobuf 对象(如 SavePlayerInfo
    • 将 Protobuf 对象 序列化为字节 → 写入文件
  2. 读档时(反序列化)

    • 从文件 读取字节
    • 反序列化 → 得到内存中的 Protobuf 对象(如 SavePlayerInfo 实例)
    • 直接使用这个对象的数据 → 调用 player.Recover(savePlayerInfo)
  • 序列化:把“活人”(游戏对象)做成“档案”(二进制文件)
  • 反序列化:把“档案”还原成“纸质简历”(C# 对象)
  • Recover:HR(游戏系统)根据“简历”重新雇佣这个人(恢复游戏状态)

    存档(Save)时的联动

    1. 构建 Protobuf 数据结构

    • 创建 SaveInfoMain(全局数据):

      • 场景名(scene
      • 玩家状态(SavePlayer(UnitManager.Instance.player) → SavePlayerInfo
      • 背包格子数据(SetSaveInfo_BagGrild → 填充 grilds map)
    • 创建 SaveLevelInfo(关卡数据):

      • 所有 NPC 的状态(通过 FSM 组件 → SavePlayerInfo
      • 所有宝箱状态(ChestInfo → SaveChestInfo
      • 关卡计时器状态(Timer.GetSaveInfo() → float time

    2. 序列化并写入文件

    • 使用 ProtoHelper.ToBytes(...) 将 Protobuf 对象序列化为字节数组
    • 分别写入两个文件:
      • main.txt:存 SaveInfoMain
      • {scene}.txt:存 SaveLevelInfo

    读档(Load)时的联动

    1. 从文件读取并反序列化

    • 读取 main.txt → ProtoHelper.ToObject<SaveInfoMain>
    • 读取 {scene}.txt → ProtoHelper.ToObject<SaveLevelInfo>

    2. 加载场景并恢复状态

    • 先加载场景(GameSystem.Instance.SceneController.Load(...)
    • 在场景加载完成后回调中恢复状态:

    恢复主角

    UnitManager.Instance.player.Recover(saveInfoMain.Player);

    要求 FSMPlayer 类有 Recover(SavePlayerInfo) 方法

    恢复背包

    BagData.Instance.Recover(saveInfoMain);

    saveInfoMain.Grilds map 恢复背包数据

    恢复 NPC

    • 遍历所有 FSM 组件
    • 若其 name 在 saveLevelInfo.Ai 中,则调用 item.Recover(data)

    恢复宝箱

    • 遍历 ChestInfo 组件
    • 若其 name 在 saveLevelInfo.Chest 中,则调用 item.Recover(data)

    恢复计时器

    t_com.Recover(saveLevelInfo.Time);

    联动点:Protobuf 反序列化出结构化数据 → C# 脚本遍历游戏对象 → 调用各组件的 Recover 方法恢复状态

    读档机制——游戏场景实例的摧毁(数据机制的详解)

            「读档前必须切换场景」这个操作(LoadSceneAsync Single 模式)把当前场景全部卸载,所有场景内的 GameObject 自然都被销毁了。

    时点场景实例(NPC、宝箱、玩家等)备注
    游戏运行中存在于内存,可以被修改、销毁、创建这些都是「运行时副本」
    执行存档只把需要的数据(位置、血量、状态、是否已开宝箱等)写成文件;不存 GameObject 本身文件里只有数据,没有实例
    读档前切换场景LoadSceneAsync(next, Single) → 旧场景完全卸载 → 所有 GameObject 被销毁Unity 自带行为,与存档无关
    新场景加载完成磁盘上的 .unity 文件重新生成初始版本的实例(你在 Editor 里摆的那个状态)如果场景文件里没摆,就不会出现
    读档回调执行你用代码 Recover() 把存档数据重新赋给「新实例」如果场景里没有对应物体,或脚本没动态创建,就会「看起来消失了」

    1. 场景里有一个宝箱

    • GameObject 名字Chest_01

    • 位置(10, 0, 5)

    • 状态:已开启

    • 掉落物品:金币 × 50

    2. 点击存档时

            不会Chest_01 这个 GameObject 整体写进文件,而是只写一行数据

    {"Chest_01": {"position": { "x": 10, "y": 0, "z": 5 },"isOpened": true,"loot": "金币_50"}
    }
    • 文件里没有模型、没有贴图、没有碰撞体,只有**“它叫什么、在哪、是否被打开”**这些数字/字符串。

    • 这就是「只存数据,不存实例」。

    3. 读档时

    • Unity 先把场景重新加载(磁盘上的 .unity 文件重新生成一个新的 Chest_01)。

    • 你的代码 Find("Chest_01") 找到这个新物体,再把存档里的 isOpened = true 赋给它 → 它变成「已开启」状态。

    代码流程——在登陆页面打开游戏并加载

    1、获取按钮组件并绑定对应方法

    1、如果点击“新游戏”,则(根据Bake文件夹是否存在)备份存档且进入加载页面(并且在加载页面中加载下一场景的对应资源)

    备份存档相关代码如下

    GameSave——NewGamehttps://blog.csdn.net/2303_80204192/article/details/153051173#t18

    using System;
    using System.Collections;
    using System.Collections.Generic;
    using UnityEngine;
    using UnityEngine.UI;public class LoginView : View
    {Button ContinueBtn;public override void Awake(){base.Awake();var new_game = transform.Find("Btn/NewGame").GetComponent<Button>();new_game.onClick.AddListener(NewGame);ContinueBtn = GetComponent<Button>("Btn/Continue");ContinueBtn.onClick.AddListener(ContinueBtnOnClick);if (GameSave.Instance.HasSaveVersion()){}else{}}private void ContinueBtnOnClick(){GameSave.Instance.Load();}private void NewGame(){//打开Loading //切换场景//更新场景的加载进度GameSystem.Instance.SceneController.Load("Game", false, BagData.Instance.Init);GameSave.Instance.NewGame();}

    此外,还有代码根据是否有存档记录来判断登录页面是否有“继续游戏”的选项

    public override void OnEnable(){base.OnEnable();ContinueBtn.gameObject.SetActive(GameSave.Instance.HasSaveVersion());}

    2、NavView导航面板退出游戏

    这个 QuitGame() 方法是一个 “退出游戏”操作的完整流程控制函数。

    其核心功能是在用户确认退出时,自动保存当前游戏进度,并返回到初始登录界面(如主菜单或登录页),同时关闭当前 UI。

    在此处点击确认就开始存档:

    GameSave.Instance.Save(0, GameSystem.Instance.SceneController.GetActiveScene());
    private void QuitGame()
    {// 1️⃣ 弹出确认提示框TipsViewController.Instance.Show("您是否退出游戏?将进行数据存档.", () => {// ✅ 用户点击“确认”时执行以下逻辑:// 2️⃣ 自动存档:保存当前场景和游戏状态GameSave.Instance.Save(0, GameSystem.Instance.SceneController.GetActiveScene());// 3️⃣ 关闭当前界面(比如设置菜单、暂停菜单等)Close(false);// 4️⃣ 关闭所有已打开的 UI 界面(防止残留)ViewManager.Instance.CloseAll?.Invoke(false);// 5️⃣ 加载初始场景(如主菜单或登录场景)GameSystem.Instance.SceneController.Load("Init", false, () => {// 6️⃣ 场景加载完成后,打开登录界面LoginViewController.Instance.Open();});}, () => { // ❌ 用户点击“取消”时执行:Close(false); // 仅关闭当前提示或菜单,不退出});
    }

    2.1这里有个比较新奇的函数方法

    在这里确立了点击导航提示板中的"您是否退出游戏?将进行数据存档."——“点击确认”的事件和“点击取消”的事件。

    public class TipsViewController : ViewController<TipsViewController, TipsView>
    {public void Show(string tips, Action enter = null, Action cancel = null){Open();SetTipsToTop();view.Show(tips, enter, cancel);}
    }将提示板中的点击——确认和取消设为事件方法
    public class TipsView : View
    {public void Show(string tips, Action enter, Action cancel){SetText(Info, tips);this.EnterAction = enter;this.CancelAction = cancel;}

    3、存档机制

    GameSave——Save方法https://blog.csdn.net/2303_80204192/article/details/153051173#t2

            它的作用是 将当前游戏进度(包括全局状态和当前关卡状态)序列化并保存到磁盘文件中。将全局数据(玩家信息)与关卡数据(NPC状态、宝箱状态、计时器状态)进行存档。

    4、继续游戏

            在QuitGame方法中,再次打开了登录页面LoginView,点击“继续游戏”

    private void ContinueBtnOnClick(){GameSave.Instance.Load();}

            加载之前的存档内容,并调用Load方法——加载存档并且将存档中的对应数据恢复至游戏内容中

    GameSave——Load方法https://blog.csdn.net/2303_80204192/article/details/153051173#t12

            在场景加载结束后正式使用存档数据恢复场景实例

    异步加载场景 + 恢复状态https://blog.csdn.net/2303_80204192/article/details/153051173#t13

            这里需要注意先SceneController.Load(在这里将10001作为SaveInfoMain.scene传递),然后完成LoadSceneAsync异步场景更新卸载旧场景(且加载完成打开10001新场景)

            并将新场景10001中的实例,导入存档之前的数据。

    存档时的一些Bug

    攻击对象重置但是却记录了引用(监听列表的未清除)

    MissingReferenceException: The object of type 'Transform' has been destroyed...

    这个错误说明:

    你保存存档时,可能保存了某个敌人的状态(比如位置、状态、HP),但没有正确恢复它的 GameObject 或 FSM 实例

    读档时:

    • 你可能 重新实例化了敌人(或没实例化);

    • 攻击逻辑里还引用着旧的 FSM 实例

    • 这个旧的 FSM 实例的 _transform 已经被销毁了。

    找到问题如下:

    报错点不在“存档”本身,而在反序列化后第一次进入战斗时,this._transform 已经被销毁,但AI 消息系统仍然调用了 OnPlayerAtk,于是访问 _transform.position 抛出 MissingReferenceException


    一、为什么会被销毁

    1. 存档时你把玩家/NPC 的运行时数据(位置、血量、状态等)写进文件。

    2. 读档时 Unity 先把旧场景完全卸载LoadSceneAsync Single 模式),所有 GameObject 被销毁,包括 this.gameObject

    3. 场景重新加载后,磁盘上的新物体已经生成,但旧的 FSM 实例(也就是 this)还残留在某个委托/事件里,没有被清掉。

    4. 当玩家再次攻击,消息系统遍历旧的监听列表,拿到一个已销毁对象的 FSM,调用 OnPlayerAtk → 访问 _transform.position → 报错

    所以要清除旧的监听列表:

    private void OnDisable()
    {if (AI){if (unitEntity.type == 3){Debug.Log($"unitEntity.info:{unitEntity.info}");MainViewController.Instance.EnableBossHP(false, unitEntity.info);}RemoveListener();}
    }public void RemoveListener()
    {GameEvent.OnPlayerAtk -= OnPlayerAtk;
    }

    不随场景卸载而自动清空,也不感知 GameObject 是否被销毁
    只要旧实例曾经 += 注册 过,它的方法地址就一直留在委托链里;场景切换时 Unity 只销毁了 GameObject 和组件,但没有帮你把委托链里的空壳踢掉,于是:

    1. 旧场景卸载 → 物体全灭。

    2. 新场景加载 → 新物体注册同名事件

    3. 事件触发 → 委托链先执行旧实例(只剩一个空壳)→ 访问任何成员都报 MissingReferenceException

    因此这里要将切换场景时,物体及物体组件摧毁时绑定移除旧的监听表

    对象池中一些可复用的物件被摧毁

    存档时,某些技能/暗器 GameObject 仍处于“激活”状态(未被回收到对象池),但它们的 GameObject 或其父对象在读档时已被销毁或重建,而对象池中仍保留着对这些已销毁对象的引用

    http://www.dtcms.com/a/483598.html

    相关文章:

  1. 什么叫网站后台如何设置网站名字吗
  2. Product Hunt 每日热榜 | 2025-10-14
  3. 网站建设 说明太原手机模板建站
  4. 佛山企业网站seo手机网站翻译成中文
  5. 在Amazon Athena中轻松在线解密Glue DataBrew加密数据:一种无缝的数据安全实践
  6. 7.DSP学习记录之数码管
  7. AI的基本知识
  8. 自定义排序
  9. 我要做网站建设网站需要多少费用
  10. Java网络通讯数据封装艺术:从字节流到业务对象的完美转换
  11. 智能垃圾桶MUC方案开发设计
  12. 新手建网站推荐用c 做的网站怎么打开
  13. 层次隐马尔可夫模型:理论与应用详解
  14. 河南企业网站排名优化价格网站开发的必要性
  15. ps做网站需注意什么陕西网站制作公司排名
  16. 青岛城阳做网站wordpress标题修改
  17. 【python学习】文件操作
  18. 安卓上怎么做单机网站什么网站可以做英语题
  19. 营销型网站上海制作简约网站首页
  20. 【详细证明 | 题解】洛谷 P2508 [HAOI2008] 圆上的整点 [数学]
  21. 化州市建设局网站淘宝联盟怎么建设网站
  22. 为什么函数会被变量“覆盖”?三大语言命名机制解析
  23. 第一个 Vue 程序:从入门到实战笔记(初学者专属)
  24. 常见网站安全攻击手段及防御方法
  25. 4.Windows Server 磁盘管理
  26. 从告警风暴到根因定位:SigNoz+CPolar让分布式系统观测效率提升10倍的实战指南
  27. 互联网站安全网站的建站方案
  28. 分布式事务:本地消息表原理与实现详解
  29. sns社交网站有哪些焦作网站开发
  30. Python全栈(基础篇)——Day11:函数进阶(高阶函数+作用域+匿名函数+实战演示+每日一题)