【Unity】RPG2D龙城纷争(五)关卡编辑器之地图编辑

更新日期:2024年6月25日。
项目源码:本章发布

索引

  • 简介
    • 关卡编辑器窗口类(LevelEditor)
      • 一、定义关卡编辑器窗口类
      • 二、两种编辑模式
      • 三、地块编辑模式
        • 1.关卡模板
        • 2.打开编辑窗口
        • 3.编辑器基本属性
        • 4.地块模板
        • 5.重新生成地图
        • 6.地图刷子
        • 7.刷地块
    • 源码链接

简介

关卡编辑器将是我们配置关卡地形,配置角色及要诀的重要工具,且关卡编辑器应当能够从0到1完成一个关卡的创建。

关卡编辑器窗口类(LevelEditor)

一、定义关卡编辑器窗口类

首先,我们定义关卡编辑器窗口类LevelEditor

    /// <summary>
    /// 关卡编辑器
    /// </summary>
    public class LevelEditor : HTFEditorWindow
    {
    
    }

LevelEditor继承至HTFEditorWindow,使其具备一些预制的功能。

二、两种编辑模式

首先,我决定将关卡编辑器分为2个模块(2种编辑模式)来完成:

  • 1.地块编辑:针对关卡地形、地块的编辑功能;
  • 2.角色编辑:针对关卡中存在的角色的编辑功能;

也即是说,关卡编辑器可以算作半个角色编辑器

/// <summary>
/// 编辑模式
/// </summary>
public enum EditMode
{
    /// <summary>
    /// 地块编辑模式
    /// </summary>
    Map,
    /// <summary>
    /// 角色编辑模式
    /// </summary>
    Role
}

本章我们先着手完成地块编辑模式。

三、地块编辑模式

1.关卡模板

首先,创建一个关卡模板(并创建为预制体),用我们在第二章建立的关卡基础层级物体:
在这里插入图片描述
然后,有必要自动化手动将关卡模板拖到场景中创建一个新的关卡这一过程,我们新增CreateNewLevel方法,并将其所处的类EditorToolKit放到Editor文件夹中,此为我们后续的编辑器工具箱类:

    internal static class EditorToolKit
    {
        [MenuItem("HTFramework/★ GameComponent/RPG2D/Create New Level", false, 320)]
        private static void CreateNewLevel()
        {
            Object asset = AssetDatabase.LoadAssetAtPath<Object>("Assets/HTFrameworkGameComponent/RunTime/RPG2D/Template/LevelTmp.prefab");
            if (asset)
            {
                GameObject level = Main.Clone(asset) as GameObject;
                level.name = "Level";
                level.transform.localPosition = Vector3.zero;
                level.transform.localRotation = Quaternion.identity;
                level.transform.localScale = Vector3.one;
                Selection.activeGameObject = level;
                EditorSceneManager.MarkSceneDirty(level.scene);
            }
            else
            {
                Log.Error("新建Level失败,丢失预制体:Assets/HTFrameworkGameComponent/RunTime/RPG2D/Template/LevelTmp.prefab");
            }
        }
    }

现在,我们就可以通过点击菜单一键创建新的关卡了:
在这里插入图片描述

2.打开编辑窗口

创建新的关卡后,我们想将关卡编辑器窗口的打开关卡预制体建立关联,那么,在Level中添加代码:

    public class Level : SingletonBehaviourBase<Level>
    {
#if UNITY_EDITOR
        [Button("打开关卡编辑器")]
        protected void OpenLevelEditor()
        {
        	//确保当前关卡处于【预制体编辑模式】,才能打开关卡编辑器
        	//这样做是为了方便我们实时保存关卡所做的修改,并排除其他可能影响我们沉迷于关卡编辑的东西
            
            //获取当前关卡对象的资源路径(如果是Scene中的物体则为null,Project中的物体则不为null)
            string path = AssetDatabase.GetAssetPath(gameObject);
            //获取当前编辑的预制体目标【预制体编辑模式】
            PrefabStage prefabStage = PrefabStageUtility.GetCurrentPrefabStage();
            //确保【预制体编辑模式】的预制体目标为当前关卡对象,string.IsNullOrEmpty(path) 确保不是选中的 Project 中的预制体
            if (string.IsNullOrEmpty(path) && prefabStage != null && prefabStage.prefabContentsRoot == gameObject)
            {
            	//通过反射打开关卡编辑器窗口(因为其位于编辑器程序集,运行时程序集不可直接访问)
                Type type = Type.GetType("HT.Framework.GC.RPG2D.LevelEditor,HTFramework.GC.Editor");
                MethodInfo method = type.GetMethod("OpenWindow", BindingFlags.Static | BindingFlags.Public);
                method.Invoke(null, new object[] { this });
            }
            else
            {
                Log.Error("请先将关卡设置为预制体,再打开预制体【Open Prefab】,再打开关卡编辑器编辑!");
            }
        }
#endif
    }

Button特性会在Level类的检视器面板绘制一个按钮,点击按钮则调用此方法:
在这里插入图片描述
LevelEditor类的打开方法:

    public class LevelEditor : HTFEditorWindow
    {
        /// <summary>
        /// 打开关卡编辑器
        /// </summary>
        public static void OpenWindow(Level level)
        {
            if (SceneView.lastActiveSceneView != null)
            {
            	//强行将Scene视图修正为2D模式
                SceneView.lastActiveSceneView.in2DMode = true;
            }

            LevelEditor levelEditor = GetWindow<LevelEditor>();
            levelEditor._level = level;
            levelEditor._blocks = level.Map.GetComponentsInChildren<Block>(true);
            levelEditor._roles.AddRange(level.RolesRoot.GetComponentsInChildren<Role>(true));
            levelEditor._editMode = EditMode.Map;
            levelEditor.titleContent.text = "关卡编辑器";
            levelEditor.minSize = new Vector2(300, 500);
            levelEditor.maxSize = new Vector2(300, 1200);
            levelEditor.Show();
        }
    }

很显然,我们刚才新创建的关卡还并未设为预制体,所以我们此时直接在上面点击打开关卡编辑器是无效的:
在这里插入图片描述

3.编辑器基本属性

然后,我们回到LevelEditor类,定义一些基本属性:

        /// <summary>
        /// 当前编辑的关卡
        /// </summary>
        private Level _level;
        /// <summary>
        /// 关卡的所有地块
        /// </summary>
        private Block[] _blocks;
        /// <summary>
        /// 关卡的所有角色
        /// </summary>
        private List<Role> _roles = new List<Role>();
        /// <summary>
        /// 地块模板
        /// </summary>
        private GameObject _blockTmp;
        /// <summary>
        /// 角色模板
        /// </summary>
        private GameObject _roleTmp;
        /// <summary>
        /// 当前的编辑模式
        /// </summary>
        private EditMode _editMode = EditMode.Map;

接着在OnBodyGUI中绘制这些属性:

        protected override void OnBodyGUI()
        {
            base.OnBodyGUI();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("关卡:", GUILayout.Width(80));
            EditorGUILayout.ObjectField(_level, typeof(GameObject), false);
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("地块数量:", GUILayout.Width(80));
            EditorGUILayout.LabelField(_blocks.Length.ToString());
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("角色数量:", GUILayout.Width(80));
            EditorGUILayout.LabelField(_roles.Count.ToString());
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("地块模板:", GUILayout.Width(80));
            _blockTmp = EditorGUILayout.ObjectField(_blockTmp, typeof(GameObject), false) as GameObject;
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("角色模板:", GUILayout.Width(80));
            _roleTmp = EditorGUILayout.ObjectField(_roleTmp, typeof(GameObject), false) as GameObject;
            EditorGUILayout.EndHorizontal();

            EditorGUILayout.BeginHorizontal();
            EditorGUILayout.LabelField("编辑模式:", GUILayout.Width(80));
            GUI.color = _editMode == EditMode.Map ? Color.white : Color.gray;
            if (GUILayout.Toggle(_editMode == EditMode.Map, "地块编辑", "ButtonLeft"))
            {
                _editMode = EditMode.Map;
            }
            GUI.color = _editMode == EditMode.Role ? Color.white : Color.gray;
            if (GUILayout.Toggle(_editMode == EditMode.Role, "角色编辑", "ButtonRight"))
            {
                _editMode = EditMode.Role;
            }
            GUI.color = Color.white;
            EditorGUILayout.EndHorizontal();

            if (_editMode == EditMode.Map)
            {
                EditMapGUI();
            }
            else if (_editMode == EditMode.Role)
            {
                EditRoleGUI();
            }

			//这一步尤其重要,当GUI界面发生变化时,将关卡物体设置为【已改变】,否则保存关卡可能会出现异常
            if (GUI.changed)
            {
                HasChanged(_level.gameObject);
            }
        }

		//地块编辑模式GUI
		private void EditMapGUI()
		{
		
		}

		//角色编辑模式GUI
		private void EditRoleGUI()
		{
		
		}

OnBodyGUI用于绘制窗口主体部分,与之对应的OnTitleGUI则用于绘制窗口标题栏。

此时打开关卡编辑器窗口便已经有一些内容了:
在这里插入图片描述

4.地块模板

按如下方式建立地块模板(我们的本意是所有地块均通过模板创建,当然之后的角色也一样):
在这里插入图片描述
然后在关卡编辑器打开时(OpenWindow),自动搜索地块模板:

levelEditor._blockTmp = AssetDatabase.LoadAssetAtPath<GameObject>("Assets/HTFrameworkGameComponent/RunTime/RPG2D/Template/BlockTmp.prefab");
5.重新生成地图

也即是按指定的长和宽重新生成地图(自动从地块模板创建长*宽数量的全部地块):

        /// <summary>
        /// 重新生成地图
        /// </summary>
        private void GenerateMap()
        {
            EditorUtility.DisplayProgressBar("重新生成地图", "正在重新生成地图......", 0);

            Block[,] blocks = new Block[_level.MapSize.x, _level.MapSize.y];
            //整理旧的地块,超出新地图尺寸的删除,不超出新地图尺寸的保留
            Block[] oldBlocks = _level.Map.GetComponentsInChildren<Block>(true);
            for (int i = 0; i < oldBlocks.Length; i++)
            {
                Block block = oldBlocks[i];
                if (block.Pos.x < _level.MapSize.x && block.Pos.y < _level.MapSize.y)
                {
                    blocks[block.Pos.x, block.Pos.y] = block;
                }
                else
                {
                    Main.KillImmediate(block.gameObject);
                }
            }
            //生成新地块,旧地图有的不生成,没有的才生成
            for (int i = 0; i < _level.MapSize.x; i++)
            {
                for (int j = 0; j < _level.MapSize.y; j++)
                {
                    if (blocks[i, j] == null)
                    {
                        GameObject obj = PrefabUtility.InstantiatePrefab(_blockTmp) as GameObject;
                        obj.name = $"Block({i},{j})";
                        obj.transform.SetParent(_level.Map.transform);
                        obj.transform.localPosition = new Vector3(1.3f * i, 1.3f * j, 0);
                        obj.transform.localRotation = Quaternion.identity;
                        obj.transform.localScale = Vector3.one;
                        obj.SetActive(true);

                        blocks[i, j] = obj.GetComponent<Block>();
                        blocks[i, j].GetComponent<SpriteRenderer>().sprite = _level.MapBrushGround;
                        blocks[i, j].Pos = new Vector2Int(i, j);

                        EditorUtility.SetDirty(blocks[i, j]);
                        EditorUtility.DisplayProgressBar("重新生成地图", "正在重新生成地图......", (float)i / _level.MapSize.x);
                    }
                }
            }
            //重新搜索所有地块
            _blocks = _level.Map.GetComponentsInChildren<Block>(true);

            EditorUtility.ClearProgressBar();
        }

这一方法将由窗口页面的按钮调用,不过由于窗口中的UI控件代码简单易懂,所以后续我便不再讲解UI界面的布局、实现等(除非一些刁钻的布局需求),因为只要看一眼源码便懂了。

那么此处,在界面上输入长和宽,点击确认按钮就可调用上面的方法,重新生成地图:
在这里插入图片描述

6.地图刷子

有了上一步生成的空白地图,接下来需要使用刷子功能,使得我们可以快速将地图上指定的地块刷为指定的类型

定义如下的刷子控制变量:

        /// <summary>
        /// 是否激活地块刷子
        /// </summary>
        private bool _isActiveMapBrush = false;
        /// <summary>
        /// 地块刷子类型名称
        /// </summary>
        private string[] _mapBrushTypeName = new string[] { "地面", "山体", "森林", "湖泊", "雪地", "障碍" };
        /// <summary>
        /// 地块刷子类型
        /// </summary>
        private BlockType _mapBrushType = BlockType.Ground;

6种地块类型,每种类型均有自己的显示图像,且不同的关卡可能不一样,甚至同一个关卡也可能不一样。

那么,我们需要存储这6种地块类型的图像,最好将其与Level类关联,使得我们下一次编辑同一关卡时有缓存功能:

    public class Level : SingletonBehaviourBase<Level>
    {
#if UNITY_EDITOR
        /// <summary>
        /// 地块刷子贴图(地面)
        /// </summary>
        [HideInInspector] public Sprite MapBrushGround;
        /// <summary>
        /// 地块刷子贴图(山体)
        /// </summary>
        [HideInInspector] public Sprite MapBrushMoutain;
        /// <summary>
        /// 地块刷子贴图(森林)
        /// </summary>
        [HideInInspector] public Sprite MapBrushForest;
        /// <summary>
        /// 地块刷子贴图(湖泊)
        /// </summary>
        [HideInInspector] public Sprite MapBrushWater;
        /// <summary>
        /// 地块刷子贴图(雪地)
        /// </summary>
        [HideInInspector] public Sprite MapBrushSnow;
        /// <summary>
        /// 地块刷子贴图(障碍)
        /// </summary>
        [HideInInspector] public Sprite MapBrushObstacle;
#endif
	}

紧接着就是在编辑器窗口关联并显示这些图像(省略UI控件代码):
在这里插入图片描述

7.刷地块

如上所示,按下键盘左上角数字1(这个位置很方便左手),便使用当前刷子类型,当前刷子贴图,在鼠标当前位置刷地块

此功能,需要在OnSceneGui(Unity编辑器回调方法)中干一些事:

        private void OnSceneGui(SceneView sceneView)
        {
            if (Event.current == null)
                return;

            if (_editMode == EditMode.Map && _isActiveMapBrush)
            {
            	//是否按下键盘1键
                if (Event.current.isKey && Event.current.keyCode == KeyCode.Alpha1 && Event.current.type == EventType.KeyDown)
                {
                	//将Scene视图坐标转换为世界坐标
                    Vector2 pos = ScreenToWorldPointInScene(sceneView.camera, Event.current.mousePosition);
                    //获取坐标位置的地块
                    Block block = GetBlockByPoint(pos);
                    if (block)
                    {
                    	//刷地块
                        block.Type = _mapBrushType;
                        switch (_mapBrushType)
                        {
                            case BlockType.Ground:
                                block.Target.sprite = _level.MapBrushGround;
                                break;
                            case BlockType.Moutain:
                                block.Target.sprite = _level.MapBrushMoutain;
                                break;
                            case BlockType.Forest:
                                block.Target.sprite = _level.MapBrushForest;
                                break;
                            case BlockType.Water:
                                block.Target.sprite = _level.MapBrushWater;
                                break;
                            case BlockType.Snow:
                                block.Target.sprite = _level.MapBrushSnow;
                                break;
                            case BlockType.Obstacle:
                                block.Target.sprite = _level.MapBrushObstacle;
                                break;
                        }
                    }
                }

                EditorGUIUtility.AddCursorRect(sceneView.position, MouseCursor.SlideArrow);
            }
        }
        /// <summary>
        /// 获取一个点所在的地块
        /// </summary>
        /// <param name="hitPos">点</param>
        private Block GetBlockByPoint(Vector2 hitPos)
        {
            Rect rect = Rect.zero;
            for (int i = 0; i < _blocks.Length; i++)
            {
                Block block = _blocks[i];
                //获取地块区域
                rect.Set(block.transform.localPosition.x - 0.65f, block.transform.localPosition.y - 0.65f, 1.3f, 1.3f);
                //如果地块区域中包含此点,则此点正在此地块上
                if (rect.Contains(hitPos))
                {
                    return block;
                }
            }
            return null;
        }

当然,同一种地形,比如地面,你使用图像1刷了若干后,很显然是可以使用图像2继续刷的,就像前面说的,同一个关卡中的同一种类型的地块显示图像是可以不一样的,也即是说地块的类型与显示图像是不相关的,这样也就给了关卡创建者更大的施展空间。

此时,地形编辑的功能便基本完备了。

源码链接

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:/a/743466.html

如若内容造成侵权/违法违规/事实不符,请联系我们进行投诉反馈qq邮箱809451989@qq.com,一经查实,立即删除!

相关文章

基于优化包络相关的Loran-C(罗兰C)信号天地波识别算法及MATLAB仿真代码和实测信号处理

引言 Loran-C接收信号处理中&#xff0c;完成信号的捕获后需要进行信号的跟踪&#xff0c;Loran-C信号的跟踪按照信号处理流程分为天地波识别、周期识别和正向过零点跟踪三个步骤。Loran-C信号通过天波和地波两种方式到达接收点&#xff0c;导致接收到信号为地波和天波的合成信…

YOLOv5改进(八)--引入Soft-NMS非极大值抑制

文章目录 1、前言2、各类NMS代码实现2.1、general.py 3、各类NMS实现3.1、Soft-NMS3.2、GIoU-NMS3.3、DIoU-NMS3.4、CIoU-NMS3.5、EIoU-NMS 4、目标检测系列文章 1、前言 目前yolov5使用的是NMS进行极大值抑制&#xff0c;本篇文章是要将各类NMS添加到yolov5中&#xff0c;同时…

性能优化-布局优化

性能优化-布局优化 屏幕的UI刷新机制 布局的选择 优化控件的使用 原生View的优化 1、屏幕的UI刷新机制 超过16ms会感觉卡顿&#xff0c; 刷新率&#xff08;Refresh Rate&#xff09;&#xff1a;指一秒内刷新屏幕的次数&#xff0c;例如60HZ&#xff1b; 帧率&#xff08;F…

菲律宾媒体PR发稿:谷歌SEO优化.关键词排名.谷歌收录

1. 引言 在菲律宾&#xff0c;媒体行业的发展日新月异&#xff0c;尤其是在线媒体。为了在这个竞争激烈的市场中脱颖而出&#xff0c;各家媒体纷纷寻求谷歌SEO优化、提升关键词排名和增加谷歌收录的方法。本文将围绕菲律宾的几大主要在线媒体&#xff0c;如菲律宾在线日志Jour…

优化系统小工具

一款利用VB6编写的系统优化小工具&#xff0c;系统优化、桌面优化、清理垃圾、查找文件等功能。 下载:https://download.csdn.net/download/ty5858/89432367

WPS复制后转置粘贴

1. WPS复制后转置粘贴 复制-》右键-》顶部第一行-》粘贴行列转置&#xff0c;如下图&#xff1a; 2. Excel office365 本地版 2. Excel office365 在线版

孙子兵法的笔记

系列文章目录 孙子兵法的笔记 三十六计的笔记 文章目录 系列文章目录1、始计第一【原文】【译文】 1、始计第一 用兵始于计谋&#xff0c;善善策出自“庙算”&#xff0c;而精确的“庙算”又来自对各种因素的侦测考察。 孙子在第一章中非常具体地提出了“五事”“七计”的考察标…

缓冲池管理器

开发环境搭建 克隆 git clone https://github.com/cmu-db/bustub.git cd bustub/ 切换分支 git checkout -b branchname v20221128-2022fall 创建docker镜像 docker build . -t bustub_img 创建容器 docker create -it --name bustub_container -v “E:/cmu/bustub”:“/bustu…

自然语言处理课程论文:《Attention is all you need》复现与解读

目录 1.背景介绍 1.1 文献介绍 1.2 研究背景 1.3 知识概述 1.3.1 机器翻译 1.3.2 attention机制与self-attention机制 2.数据来源与处理 2.1 数据集描述 2.2 数据处理 3. 模型架构 ​​​​​​​3.1 Positional Embedding ​​​​​​​3.2 Multi-Head Attention ​​​​​…

[UE虚幻引擎] DTSpeechVoice 文字转语音播放 插件说明

本插件可以在UE中使用蓝图把文本转成语音播放&#xff0c;播放的声音引擎是使用Windows自带的语音引擎&#xff0c;支持Win10&#xff0c;Win11。 系统设置 首先确认电脑是否有语音系统&#xff0c;一般正常安装的电脑都是自带的。 如果要播放多语言的&#xff0c;请自己下载其…

突发!OpenAI停止不支持国家API,7月9日开始执行

6月25日凌晨&#xff0c;有部分开发者收到了OpenAI的信&#xff0c;“根据数据显示&#xff0c;你的组织有来自OpenAl目前不支持的地区的API流量。从7月9日起&#xff0c;将采取额外措施&#xff0c;停止来自不在OpenAI支持的国家、地区名单上的API使用。” 但这位网友表示&am…

【宠粉赠书】SQLServer2022:从入门到精通

为了回馈粉丝们的厚爱&#xff0c;今天小智给大家送上一套数据库学习的必备书籍——《SQL Server 2022从入门到精通》。下面我会详细给大家介绍这套图书&#xff0c;文末留有领取方式。 图书介绍 《SQL Server 2022从入门到精通》系统全面地介绍SQL Server 2022数据库应用与开…

文献阅读:通过双线性建模来破译神经元类型连接的遗传密码

文献介绍 文献题目 Deciphering the genetic code of neuronal type connectivity through bilinear modeling 研究团队 Mu Qiao&#xff08;美国加州理工学院&#xff09; 发表时间 2024-06-10 发表期刊 eLife 影响因子 7.7 DOI 10.7554/eLife.91532.3 摘要 了解不同神经元…

【C++STL】Vector扩容机制

创作不易&#xff0c;本篇文章如果帮助到了你&#xff0c;还请点赞 关注支持一下♡>&#x16966;<)!! 主页专栏有更多知识&#xff0c;如有疑问欢迎大家指正讨论&#xff0c;共同进步&#xff01; &#x1f525;c系列专栏&#xff1a;C/C零基础到精通 &#x1f525; 给大…

“Hello, World!“ —— 初学者进入编程世界的第一步

布莱恩W.克尼汉&#xff08;Brian W. Kernighan&#xff09;—— Unix 和 C 语言背后的巨人 布莱恩W.克尼汉 布莱恩W.克尼汉在 1942 年出生在加拿大多伦多&#xff0c;他在普林斯顿大学取得了电气工程的博士学位&#xff0c;2000 年之后取得普林斯顿大学计算机科学的教授教职。…

SpringBoot开启事务日志

一般框架开启日志的方式&#xff1a; 开启某个包下的日志就写该包路径&#xff0c;开启某个类下的日志就写该类路径。

3d渲染软件有哪些(1),渲染100邀请码1a12

3D渲染是把三维模型转成2D图像的过程&#xff0c;领域不同常用的软件也不一样&#xff0c;今天我们就简单介绍几个。 在介绍前我们先推荐一个设计人员常用到的工具&#xff0c;就是网渲平台渲染100&#xff0c;通过它设计师可以把本地渲染放到云端进行&#xff0c;价格也不贵&a…

PCL笔记二 之VS环境配置(不同版本Debug+Release编译)

PCL笔记二 之VS环境配置&#xff08;不同版本DebugRelease编译&#xff09; PCL官网&#xff1a;https://github.com/PointCloudLibrary/pcl/releases众所周知&#xff0c;PCL是一个用于点云处理并且依赖不少三方库的一个算法库&#xff0c;同时在编译配置环境时也很复杂&…

【嵌入式DIY实例】-Nokia 5110显示BME280传感器数据

Nokia 5110显示BME280传感器数据 文章目录 Nokia 5110显示BME280传感器数据1、硬件准备与接线2、代码实现本文将介绍如何使用 ESP8266 NodeMCU 板(ESP12-E 模块)和 BME280 气压、温度和湿度传感器构建一个简单的本地气象站。 NodeMCU 从 BME280 传感器读取温度、湿度和压力值…

轻松打造分班查询系统,这个工具助您一臂之力!

新学期伊始&#xff0c;老师们知道该如何快捷制作并发布分班查询系统吗&#xff1f;面对繁杂的学生名单和班级分配&#xff0c;无疑是一项巨大的麻烦。传统的纸质通知效率低下&#xff0c;容易出错&#xff0c;更别提在信息传递过程中可能出现的混乱和误解了。 现在有一个工具可…