游戏对象与图形基础

2023-11-07

游戏对象与图形基础

这是有游戏编程的第四次作业,对MVC深入

说明文档

本次实验完成了所有基本要求,尽量将步骤展示出。
闪光点:
新增类图以及详细的代码注释来解析动作分离
对游戏改造时出现的问题进行分析和解决

作业内容

1. 基本操作演练【建议做】

  • 下载 Fantasy Skybox FREE, 构建自己的游戏场景

    • 选择天空盒:

      • 在菜单栏中依次点击Window > Rendering > Lighting Setting
      • 在跳出的弹窗中选择Environment选项,将采用的天空盒拖入下面的红框中:
      • 所选择的天空盒是:
    • 创建普通地形,并种花种草:

    • 补充说明上一步中如何种花草:

      • 在地形的Inspector中点击如下图标:
      • Details栏中点击红框所示选项:
      • 在弹出的二级菜单中点击Add Grass Texture
      • 在跳出的窗口中,将花草的材质拖入图中上方的红框中,并点击右下方的Add
    • 效果展示:

  • 写一个简单的总结,总结游戏对象的使用

    • 空对象:空对象多被用于挂载游戏脚本。
    • 3D Object:是游戏世界里面的组成元素,通过Transform等属性来改变他们的位置和状态。
    • 地形等:既是游戏世界的组成元素,又是一个编辑工具,附带了各种工具用于编辑地形和种花种草。
    • 摄像头:是游戏世界的视角观察工具,通常或作为玩家的眼睛,比如第一人称游戏。还有的是跟随游戏人物移动的地图摄像机。

2. 编程实践

  • 牧师与魔鬼 动作分离版
    • 【2019开始的新要求】:设计一个裁判类,当游戏达到结束条件时,通知场景控制器游戏结束
需求分析

  这一次的作业建立在上一次作业的基础上,在这次的设计里面,我们需要把动作的管理抽取为一个裁判的角色,进行进一步的角色专职管理。

  首先看看上一次作业的UML类图,我们可以看到:


  MoveController控制着游戏对象的移动,但是运动不论是基本还是组合动作都耦合在一起,同时,裁判的判断职责以及具体控制对象的移动的逻辑都在FirstController中,这将导致FirstController的职责不再单一,我们考虑使用恰当的接口设计来使得这些角色进一步分离。

  基于以上判断,动作分离版的新特性可列举如下:

  • 使用专门的对象来管理运动,包括基本动作、组合动作。并且,不需要再像旧版一样每个对象都加上Move组件。
  • 游戏中新增裁判类,当游戏达到结束条件时,通知场景控制器游戏结束。在旧版中,FirstController使用Check函数判断游戏是否结束。现在将游戏的胜利与失败交给裁判管理,场景控制器新增处理裁判的反馈的回调函数即可,进一步解耦合职责。
新版的设计与实现

新版的设计思想里借鉴了老师课件,我觉得总结的很好,我摘抄并加上自己的理解展示如下:

  • 通过门面模式(控制器模式)来输出几个预先设计好的动作,供场景控制器使用
    • 好处:动作的具体组合逻辑变成动作模块的内部事务,不需要场景控制器操心具体运动
    • 在新版设计中,这个门面也就是CCActionManager
  • 通过组合模式实现动作组合,按照组合模式的设计方法
    • 必须要有一个接口表示事物的共性和行为,例如新版设计中的SSAction,表示动作,它可以表示基本动作也可以表示组合基本动作。
    • 基本动作:用户设计的基本动作类,如CCMoveToAction
    • 组合动作:由(基本或组合)动作组合的类,如CCSequenceAction
  • 接口回调(函数回调)实现管理者与被管理者解耦
    • 如组合对象实现一个事件抽象接口(ISSActionCallback),作为监听器监听子动作的事件。
    • 被组合对象使用监听器传递消息给管理者。至于管理者如何控制子动作顺序或者逻辑就是实现这个监听器接口的负责人说了算,但”别人敢实现,我就敢用“。
    • 前两点是很重要的,子动作序列操控着物体移动,而顺序便是最开始定义的,然而每一次移动之间的衔接如何进行呢?其实就是通过接口回调通知组合动作对象说:嘿,我这个子动作完成了,你可以接着下一个子动作了。
  • 通过模板方法,让使用者减少对动作管理过程细节的要求
    • SSActionManager作为CCActionManager基类。

最终,我们将可以很方便的定义动作并实现动作的自用组合,程序更易于适应需求变化和易于维护,对象更容易被复用。

旧版的代码结构图以及类图可参考我上一篇作业

  • 新版的代码结构图:

    • 在上一次的MVC结构上,这一次新增Actions
  • 新版的UML

  • 新增代码的讲解(我的注释都在代码中,都是细节呀!)

    • 动作基类(SSAction):
      // 动作基类SSAction
      // ScriptableObject 是不需要绑定 GameObject 对象的可编程基类。
      // 这些对象受 Unity 引擎场景管理
      public class SSAction : ScriptableObject {
      
        public bool enable = true;
        public bool destroy = false;
      
        public GameObject gameObject { get; set; }
        public Transform transform { get; set; }
        // 利用接口(ISSACtionCallback)实现消息通知,
        // 避免与动作管理者直接依赖
        public ISSActionCallback callback { get; set; }
        // protected 防止用户自己 new 抽象的对象
        protected SSAction() { }
      
        // 使用 virtual 申明虚方法,通过重写实现多态。
        // 这样继承者就明确使用 Start 和 Update 编程游戏对象行为
        // Start is called before the first frame update
        public virtual void Start() {
          throw new System.NotImplementedException();
        }
      
        // Update is called once per frame
        public virtual void Update() {
          throw new System.NotImplementedException();
        }
      }
      
    • 简单动作实现(CCMoveToAction):
      // 简单动作实现
      public class CCMoveToAction : SSAction {
        public Vector3 target; // 目的地
        public float speed;// 速度
      
        private CCMoveToAction() { }
      
        // 工厂函数
        public static CCMoveToAction GetSSAction(Vector3 target, float speed) {
          // 让Unity来创建动作类,确保内存正确回收。
          CCMoveToAction action = ScriptableObject.CreateInstance<CCMoveToAction>();
          action.target = target;
          action.speed = speed;
          return action;
        }
      
        // Start is called before the first frame update
        public override void Start() {
      
        }
      
        // Update is called once per frame
        public override void Update() {
          // 首先判断是否符合移动条件
          if (this.gameObject == null || this.transform.localPosition == target) {
            this.destroy = true;
            this.callback.SSActionEvent(this);
            return;
          }
          // 可以移动
          this.transform.localPosition = Vector3.MoveTowards(this.transform.localPosition, target, speed * Time.deltaTime);
        }
      }
      
    • 顺序动作组合类实现(CCSequenceAction):
      public class CCSequenceAction : SSAction, ISSActionCallback {
      
        public List<SSAction> sequence; // 动作序列
        public int repeat = -1; // 重复次数,默认为无限
        public int start = 0;
      
        // 工厂函数
        public static CCSequenceAction GetSSAction(int repeat, int start, List<SSAction> sequence) {
          CCSequenceAction action = ScriptableObject.CreateInstance<CCSequenceAction>();
          action.repeat = repeat;
          action.start = start;
          action.sequence = sequence;
          return action;
        }
      
        // 对序列中的动作进行初始化
        // Start is called before the first frame update
        public override void Start() {
          // 执行动作前,为每个动作注入当前动作游戏对象,
          // 并将自己作为动作事件的接收者
          foreach (SSAction action in sequence) {
            action.gameObject = this.gameObject;
            action.transform = this.transform;
            action.callback = this;
            action.Start();
          }
        }
      
        // 运行序列中的当前动作
        // Update is called once per frame
        public override void Update() {
          if (sequence.Count == 0) {
            return;
          }
          if (start < sequence.Count) {
            sequence[start].Update();
          }
        }
        // 回调处理,当有动作完成时触发该函数
        public void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Computed, int intParam = 0, string strParam = null, Object objectParam = null) {
          // 收到当前动作完成的通知,
          // 应该进行下一个动作
          source.destroy = false;
          this.start++;
          if (this.start >= sequence.Count) {
            // 如果完成一次循环,减掉次数
            this.start = 0;
            if (repeat > 0) {
              repeat--;
            }
            // 如果次数减为0,代表所有动作结束,
            /// 应该通知接收者。
            if (repeat == 0) {
              this.destroy = true;
              this.callback.SSActionEvent(this);
            }
          }
        }
        void onDestroy() { }
      }
      
    • 动作事件接口定义(ISSActionCallback):
      接口作为接收通知对象(参数source)的抽象类型。
      // 定义事件类型
      public enum SSActionEventType : int { Started, Computed }
      public interface ISSActionCallback {
        // 事件处理接口,所有事件管理者都必须实现这个接口
        // 来实现事件调度。
        // 所以sequenceAction、ActionManager都必须实现它
        void SSActionEvent(SSAction source, SSActionEventType events = SSActionEventType.Computed, int intParam = 0, string strParam = null, Object objectParam = null);
      }
      
    • 动作管理基类(SSActionManager):
      这是动作对象管理器的基类,实现了所有动作的基本管理。
      public class SSActionManager : MonoBehaviour {
      
        // 动作字典集
        public Dictionary<int, SSAction> actions = new Dictionary<int, SSAction>();
        // 等待被加入的动作队列,这是即将开始的动作
        private List<SSAction> waitingAdd = new List<SSAction>();
        // 等待被删除的动作队列,这是已经完成的动作
        private List<int> waitingDelete = new List<int>();
      
        // Start is called before the first frame update
        protected void Start() {
      
        }
      
        // Update is called once per frame
        protected void Update() {
          // 首先将waitingAdd中的动作保存
          foreach (SSAction action in waitingAdd) {
            actions[action.GetInstanceID()] = action;
          }
          waitingAdd.Clear();
      
          // 运行被保存的动作
          foreach (KeyValuePair<int, SSAction> kv in actions) {
            SSAction ac = kv.Value;
            if (ac.destroy) {
              waitingDelete.Add(ac.GetInstanceID());
            } else if (ac.enable) {
              ac.Update();
            }
          }
      
          // 删除waitingdelete中的动作
          foreach (int key in waitingDelete) {
            SSAction ac = actions[key];
            actions.Remove(key);
            Destroy(ac);
          }
          waitingDelete.Clear();
        }
      
        // 此方法用于添加新动作,该方法把游戏对象与动作绑定
        // 并绑定该动事件的消息接收者
        public void RunAction(GameObject gameObject, SSAction action, ISSActionCallback manager) {
          action.gameObject = gameObject;
          action.transform = gameObject.transform;
          action.callback = manager;
          waitingAdd.Add(action);
          action.Start();
        }
      }
      
      这里回答一下老师的问题,即动作集合设计中字典是线程不安全的,有影响吗?其实是没影响的,首先能够管理动作集合的类对象只有一个,而单个游戏对象的事件循环都是单线程的,对动作集合的修改没有并发访问或修改的问题。

  上面就是动作管理者的实现。除此之外,我们还设计一个裁判类,裁判类其实是一个控制器:

public class JudgeController : MonoBehaviour {

  public FirstController mainController;
  public LandModel leftLandModel;
  public LandModel rightLandModel;
  public BoatModel boatModel;

  // Start is called before the first frame update
  void Start() {
    mainController = (FirstController)SSDirector.GetInstance().CurrentSenceController;
    this.leftLandModel = mainController.leftLandRoleController.GetLandModel();
    this.rightLandModel = mainController.rightLandRoleController.GetLandModel();
    this.boatModel = mainController.boatModelController.GetBoatModel();
  }

  // Update is called once per frame
  void Update() {
    if (!mainController.isRunning) {
      return;
    }
    //  如果时间消耗尽
    if (mainController.time <= 0) {
      mainController.JudgeCallback(false, "Game Over!");
    }
    // 否则游戏还在进行
    this.gameObject.GetComponent<UserGUI>().gameMessage = "";
    // 判断是否已经胜利
    if (leftLandModel.priestNum == 3) {
      mainController.JudgeCallback(false, "You Win!");
      return;
    } else {
      int leftPriestNum, leftDevilNum, rightPriestNum, rightDevilNum;

      leftPriestNum = leftLandModel.priestNum + (boatModel.onRight ? 0 : boatModel.priestNum);
      leftDevilNum = leftLandModel.devilNum + (boatModel.onRight ? 0 : boatModel.devilNum);
      if (leftPriestNum != 0 && leftDevilNum > leftPriestNum) {
        mainController.JudgeCallback(false, "Game Over!");
        return;
      }

      rightPriestNum = rightLandModel.priestNum + (boatModel.onRight ? boatModel.priestNum : 0);
      rightDevilNum = rightLandModel.devilNum + (boatModel.onRight ? boatModel.devilNum : 0);
      if (rightPriestNum != 0 && rightDevilNum > rightPriestNum) {
        mainController.JudgeCallback(false, "Game Over!");
        return;
      }
    }
  }
}

那么对应的,我们的场景控制器FirstController也要进一步修改,来接入裁判和动作管理,代码有点长,我类似diff一个列出变化,突出这一次的新设计的接入。

public class FirstController : MonoBehaviour, ISceneController, IUserAction {
  // 新增动作管理类
  public CCActionManager actionManager;
  // 不在需要
  // public MoveController moveController;

  // 新增函数,用于让裁判通知游戏状态以及消息
  public void JudgeCallback(bool isRunning, string msg) {
    this.gameObject.GetComponent<UserGUI>().gameMessage = msg;
    this.gameObject.GetComponent<UserGUI>().time = (int)time;
    this.isRunning = isRunning;
  }

  public void LoadResources() {

    // 移动控制器实例化已经不需要
    // moveController = new MoveController();
  }

  public void MoveBoat() {
    if ((!isRunning) || actionManager.IsMoving()) {
      return;
    }
    Vector3 target = boatModelController.GetBoatModel().onRight ? PositionModel.left_boat : PositionModel.right_boat;
    // 直接交给动作管理进行
    actionManager.MoveBoat(boatModelController.GetBoatModel().boat, target, 5);
    // 方向取反
    boatModelController.GetBoatModel().onRight = !boatModelController.GetBoatModel().onRight;
  }

  public void MoveRole(RoleModel roleModel) {
    if ((!isRunning) || actionManager.IsMoving()) {
      return;
    }

    Vector3 destination, mid_destination;

    if (roleModel.onBoat) {
      // 在船上
      if (boatModelController.GetBoatModel().onRight) {
        destination = rightLandRoleController.AddRole(roleModel);
      } else {
        destination = leftLandRoleController.AddRole(roleModel);
      }
      if (roleModel.role.transform.localPosition.y > destination.y) {
        mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z);
      } else {
        mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z);
      }
      // 直接交给动作管理进行
      actionManager.MoveRole(roleModel.role, mid_destination, destination, 5);

      // 角色的方向不能每次取反,因为可以在同一边重复上下船
      roleModel.onRight = boatModelController.GetBoatModel().onRight;
      boatModelController.RemoveRole(roleModel);
    } else {
      // 在陆地并且船未满
      if (boatModelController.GetBoatModel().onRight == roleModel.onRight && boatModelController.GetBoatModel().devilNum + boatModelController.GetBoatModel().priestNum < 2) {
        if (roleModel.onRight) {
          rightLandRoleController.RemoveRole(roleModel);
        } else {
          leftLandRoleController.RemoveRole(roleModel);
        }
        destination = boatModelController.AddRole(roleModel);
        if (roleModel.role.transform.localPosition.y > destination.y) {
          mid_destination = new Vector3(destination.x, roleModel.role.transform.localPosition.y, destination.z);
        } else {
          mid_destination = new Vector3(roleModel.role.transform.localPosition.x, destination.y, destination.z);
        }
        // 直接交给动作管理进行
        actionManager.MoveRole(roleModel.role, mid_destination, destination, 5);
      }
    }
  }

  public void Restart() {
    // 必须实例化一个新的裁判,否则使用的是历史数据
    Destroy(this.gameObject.GetComponent<JudgeController>());
    this.gameObject.AddComponent<JudgeController>();
  }

  private void Awake() {
    SSDirector.GetInstance().CurrentSenceController = this;
    LoadResources();
    this.gameObject.AddComponent<UserGUI>();
    this.gameObject.AddComponent<CCActionManager>();
    this.gameObject.AddComponent<JudgeController>();
  }

  private void Update() {
    if (isRunning) {
      time -= Time.deltaTime;
      this.gameObject.GetComponent<UserGUI>().time = (int)time;
      // 不再具体管控判断和运动逻辑
    }
  }
}

其他改动:

  • IUserAction接口移除 Check()方法,修改过后,程序即可运行,效果与上个版本一样,就不重复贴图了。

  • 遇到的问题与解决办法:

    • 在这次的实验里面,由于相隔了一个星期,所以不免有点记不清之前的代码逻辑,所以在调试游戏时,出现了一个问题:第一次进入游戏的时候,一切正常,船可以左右移动,但是,当船移动到左边并输掉游戏时点击Restart之后,新游戏里面如果我让牧师上船,船还没移动就显示游戏结束了。但是我通过Debug.Log输出此时右边(包括岸和船上)的牧师数量时,却发现没问题:
    • 分析以及解决:
      • 第一次是怀疑在运动过程中,对象没有在加到船上时裁判就介入判断,所以出现了误判。所以我在裁判的判断的加入了:
        void Update() {
            if (mainController.actionManager.isMoving //这是防止在运动时判断
            	|| !mainController.isRunning) {
              return;
            }
            // ..
        }
        
        但是这没有解决问题,为什么呢?因为阅读船的AddRole源码就知道人物在移动前其引用已经添加到船模型中,所以根本不存在运动时角色引用不在船上的问题(不然和上面的日志矛盾了),而且上面的补充代码也没啥用,反而使得游戏的输赢判断在运动结束时才进行。
      • 后来通过分析刚才的日志,根据经验以及分析,发现场景控制器在Restart的时候,并没有新实例化一个裁判类!这将导致裁判类仍然引用着上一次游戏的角色、船的模型,是旧的数据,这不仅会导致内存无法释放,还会使得游戏发生错误,于是发现问题后,我在场景控制器的重启函数中加入了如下代码:
        public void Restart() {
          // 必须实例化一个新的裁判,否则使用的是历史数据
          Destroy(this.gameObject.GetComponent<JudgeController>());
          this.gameObject.AddComponent<JudgeController>();
        }
        
        这样子问题就解决了。
  • 如果想要了解更多的代码细节,请移步我的gitee仓库

本文内容由网友自发贡献,版权归原作者所有,本站不承担相应法律责任。如您发现有涉嫌抄袭侵权的内容,请联系:hwhale#tublm.com(使用前将#替换为@)

游戏对象与图形基础 的相关文章

随机推荐

  • Mybatis返回自增主键id的值,2种方式

    1 方式一 不建议使用 有BUG的方式 通过useGeneratedKeys true keyProperty id 来设置返回新的id值 这里有个问题就是 通过这种方式插入的值 经常会返回1 原因是因为他这里的意思是返回当前影响的行数 不
  • lc marathon 7.16

    文章目录 138 复制带随机指针的链表 https leetcode cn problems copy list with random pointer 剑指 Offer II 092 翻转字符 https leetcode cn prob
  • c++ 自定义时间格式

    1 自定义时间格式 我们可以使用strftime 函数将时间格式化为我们想要的格式 它的原型如下 size t strftime char strDest size t maxsize const char format const str
  • 关于HR系统升级为集团版的设计总结

    刚刚完了公司HR系统的升级 系统实现了从单一公司使用到多公司使用的转变 在升级的一个多月的时间内 虽然很苦 但感觉自已在系统架构上受益非浅 具体有以下感悟 一 MVC还是很重要 系统框架是五年前用delphi设计的 采用的基类加扩展类的模式
  • fluent p1模型_Fluent辐射传热模型理论以及相关设置(一)

    原标题 Fluent辐射传热模型理论以及相关设置 一 本文来源于网络 原作者 Libo CHen 感谢作者的辛苦整理和撰写 1概述 在传热的仿真中 有时候会不可避免的涉及到辐射传热 而我们对Fluent中辐射模型的了解甚少 很难得到可靠的计
  • 关于Unity-Web的Development build。

    Unity转手游之后是否该勾选development Build 首先看一看官方文档的解释 Development Build When you check the Development Build checkbox Unity gene
  • 【杂记】EMC、EMI、EMS、TVS、ESD概念学习总结

    注 杂记 系列为日常网搜资料的简单堆砌而积累成之 如有错误恭谢指出 标识为 原创 其实不完全是 只是多引用再整理 大都引自网络 侵删 EMC EMC Electromagnetic Compatibility 属于概念 为电磁兼容性 电磁兼
  • java 中unsigned类型的转换

    java 中unsigned类型的转换
  • oh-my-zsh安装与常用插件

    zsh 介绍 工欲善其事 必先利其器 zsh也是一种 shell 兼容最常用的 bash 这种 shell 的命令和操作 bash 虽然很标准 但是自己日常使用方便更重要 oh my zsh 提供了丰富的插件和提 安装 先使用命令查看系统支
  • whisper:robust speech recognition via large-sacle weak supervision

    OpenAI Whisper 精读 论文精读 45 哔哩哔哩 bilibili更多论文 https github com mli paper reading 视频播放量 68331 弹幕量 327 点赞数 2332 投硬币枚数 1192 收
  • QJsonObject的使用示例

    介绍 负责封装JSON对象 是键 值对列表 其中键是惟一的字符串 值由QJsonValue表示 1 QJsonObjec 封装了Json里的对象 接口与QMap相似 都具有size insert 和remove 等操作 还可以使用标准C 迭
  • SQL Server(三)-查询数据(2)

    函数与分组查询数据 一 系统函数 在SQL Server 2008中系统函数是指在SQL Server 2008中自带的函数 主要分为聚合函数 数据类型转换函数 日期函数 数学函数及其他一些常用的函数 1 聚合函数 对一组值进行计算 然后返
  • 雅思词汇表8000词版_你别不信,雅思光靠背单词也能上6.5!

    点击上方蓝字关注我哦 打算备考雅思 大家都是知道IELTS考试对词汇量要求是比较高的 如果自身的英语基础薄弱 想短时间内在雅思成绩上有所突破是很困难的一件事情 因此很多考生会 病急乱投医 购买各式各样雅思词汇手册进行疯狂记忆 每本单词手册都
  • Keil 重定向 fputc 函数 以及 printf 函数的代码尺寸测试

    本文的开发环境为 Keil Cortex M3 内核处理器 重定向 fputc 函数方法 如果想使用库函数 printf 必须要将 fputc 重定向到自己的串口上 术语 重定向 可以理解为用户重写 fputc 函数 在重写的函数体内调用自
  • vulnhub-KIOPTRIX: LEVEL 1.3 (#4)-KioptrixVM4靶场

    以下演示均在测试环境进行 遵纪守法 靶场下载地址 Kioptrix Level 1 3 4 VulnHub 镜像下载解压之后是一个 vhd文件 需要新建虚拟机 虚拟机操作系统任选一个linux系列的系统 一直下一步 到了设置磁盘 按照截图设
  • Qt系列文章之(十) ui文件的使用

    上一篇文章在主函数中构造了一个简单的主窗口界面 继承了一些基本元素 如菜单栏 工具栏 悬浮窗口 主界面等元素 不过这些元素都是在栈区开辟的临时变量 放在主函数里面来实现 这不是一种标准的UI界面开发手段 一般在界面项目开发之中有几个典型的开
  • PyCharm远程连接失败、错误,报错:Can‘t connect...【解决方法与错误分析】

    学习网站推荐 前些天发现了一个巨牛的人工智能学习网站 通俗易懂 风趣幽默 忍不住分享一下给大家 点击跳转到网站 文章目录 一 前言 二 报错 2018版 2020版 三 错误分析 我的错误原因 其他3种可能因粗心导致的原因 四 如果你不想再
  • HTTPX从入门到放弃

    1 什么是HTTPX HTTPX是一款Python栈HTTP客户端库 它提供了比标准库更高级别 更先进的功能 如连接重用 连接池 超时控制 自动繁衍请求等等 HTTPX同时也支持同步和异步两种方式 因此可以在同步代码和异步代码中通用 HTT
  • Swift语法学习--协议基础

    文章目录 协议定义 typealias关键词类型定义新的名称 associatedtype增加协议功能 协议定义 typealias关键词类型定义新的名称 不做赘述 typealias Distance Double typealias P
  • 游戏对象与图形基础

    游戏对象与图形基础 这是有游戏编程的第四次作业 对MVC深入 文章目录 游戏对象与图形基础 说明文档 作业内容 1 基本操作演练 建议做 2 编程实践 需求分析 新版的设计与实现 说明文档 本次实验完成了所有基本要求 尽量将步骤展示出 闪光