news 2026/8/10 3:07:27

Unity游戏开发:实现《星露谷物语》式物品丢弃系统

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
Unity游戏开发:实现《星露谷物语》式物品丢弃系统

1. 项目概述与核心思路拆解

在开发一款类似《星露谷物语》的农场模拟游戏时,物品的拾取与丢弃系统是构建沉浸式世界交互体验的基石。今天要聊的这个功能点——“单击Drop项目”,听起来简单,但背后涉及到的UI交互、数据同步、场景对象生成以及游戏逻辑的连贯性,每一个环节都值得深挖。很多新手在实现时,要么是点击没反应,要么是物品丢出去了但数据没更新,或者更糟,物品在空中“复制”了一地。这个功能的目标很明确:当玩家在道具栏(Inventory)中选中一个物品后,点击游戏世界中的某个位置(比如地面),就能在该位置生成一个对应的可拾取物品实体,并且道具栏中该物品的数量要相应减少。这不仅仅是生成一个Prefab那么简单,它考验的是你对Unity事件系统、数据管理和对象生命周期的综合把控能力。

为什么这个功能如此重要?在《星露谷物语》这类游戏中,物品管理是核心玩法。玩家需要频繁地在背包、箱子、售卖箱和地面之间转移物品。一个流畅、直观且稳定的Drop操作,能极大提升游戏的“手感”和玩家的掌控感。想象一下,你辛苦挖矿得到了一堆矿石,想暂时丢在地上腾出背包空间,如果丢弃操作卡顿或者物品位置飘忽不定,那种挫败感会立刻打破游戏的沉浸氛围。因此,实现这个功能,我们追求的不仅是“能用”,更是“好用”和“稳定”。

从技术架构上看,“单击Drop”连接了游戏的UI层(道具栏界面)、数据层(玩家库存数据)和场景层(游戏世界中的实体)。我们需要监听至少两个关键事件:一是道具栏中物品的“选中”事件,二是玩家对游戏世界的“点击”事件。这两者之间的状态需要被妥善管理,确保点击发生时,我们知道要丢弃什么、丢在哪里、以及丢弃后数据如何变化。接下来,我们就从设计思路开始,一步步拆解实现细节。

2. 核心系统设计与数据流管理

2.1 事件驱动的交互状态机

实现单击Drop,最核心的是建立一个清晰的状态管理机制。我们不能让游戏世界漫无目的地监听每一次点击,然后去猜测玩家想干什么。正确的做法是引入一个“意图状态”。当玩家在道具栏UI中点击一个物品槽(Slot)时,这不仅仅是一个UI高亮操作,更应该被视为一个游戏指令的起点:玩家“意图”丢弃(或使用、移动)这个物品。

我通常会创建一个全局可访问的PlayerActionControllerInteractionManager单例类来管理这个状态。这个类内部维护几个关键变量:

public class InteractionManager : MonoBehaviour { public static InteractionManager Instance; // 当前玩家意图执行的动作类型 public enum ActionIntent { None, DropItem, UseItem, MoveItem } private ActionIntent _currentIntent = ActionIntent.None; // 与当前意图关联的物品数据 private InventoryItemData _pendingItemData; // 以及该物品在道具栏中的来源索引(方便后续更新数量) private int _pendingItemSlotIndex; void Awake() { if (Instance == null) Instance = this; else Destroy(gameObject); } // 当在UI中选中一个物品时调用 public void SetDropIntent(InventoryItemData itemData, int slotIndex) { _currentIntent = ActionIntent.DropItem; _pendingItemData = itemData; _pendingItemSlotIndex = slotIndex; // 可以在这里改变鼠标光标样式,提示玩家现在处于“丢弃模式” CursorManager.Instance.SetCursor(CursorType.Drop); } // 获取当前意图 public bool TryGetDropIntent(out InventoryItemData itemData, out int slotIndex) { if (_currentIntent == ActionIntent.DropItem && _pendingItemData != null && _pendingItemData.quantity > 0) { itemData = _pendingItemData; slotIndex = _pendingItemSlotIndex; return true; } itemData = null; slotIndex = -1; return false; } // 执行完丢弃操作后,重置意图 public void ClearIntent() { _currentIntent = ActionIntent.None; _pendingItemData = null; _pendingItemSlotIndex = -1; CursorManager.Instance.SetCursor(CursorType.Default); } }

这个状态机是整个功能的“大脑”。道具栏UI的每个Slot在点击时,不再仅仅通知UI更新,而是必须调用InteractionManager.Instance.SetDropIntent(...),将物品数据和槽位索引传递出去。这样,当玩家随后点击地面时,负责处理地面点击的脚本(如GroundClickHandler)就可以询问状态机:“玩家现在有丢弃意图吗?如果有,把要丢的物品数据给我。”

2.2 物品数据模型的构建

在丢弃之前,我们必须明确“丢弃什么”。这要求我们有一个健壮的物品数据模型。这个模型需要与UI上显示的、背包里存储的、以及最终生成在世界中的实体,保持数据的一致性。我强烈建议将数据(Data)与表现(View)分离。

首先,定义核心数据类InventoryItemData

[System.Serializable] public class InventoryItemData { public string itemId; // 物品唯一标识,对应配置表 public string itemName; public Sprite icon; public int quantity; // 当前堆叠数量 public int maxStack; // 最大堆叠数 public GameObject worldPrefab; // 对应在场景中生成的Prefab // 深拷贝方法,防止引用传递导致意外修改 public InventoryItemData Clone() { return new InventoryItemData { itemId = this.itemId, itemName = this.itemName, icon = this.icon, quantity = this.quantity, maxStack = this.maxStack, worldPrefab = this.worldPrefab }; } }

其次,需要一个管理所有物品的中央仓库InventoryManager。它负责存储玩家背包的数组或列表,并提供增加、删除、查询和更新数量的方法。当执行Drop操作时,我们不是直接从UI绑定的数据里扣减数量,而是通过InventoryManager来执行,确保数据变化的唯一入口。

public class InventoryManager : MonoBehaviour { public List<InventoryItemData> items = new List<InventoryItemData>(30); // 假设背包30格 // 尝试从指定槽位移除指定数量的物品 public bool TryRemoveItemFromSlot(int slotIndex, int amountToRemove) { if (slotIndex < 0 || slotIndex >= items.Count) return false; InventoryItemData item = items[slotIndex]; if (item == null || item.quantity < amountToRemove) return false; item.quantity -= amountToRemove; if (item.quantity <= 0) { // 数量为0,清空该槽位 items[slotIndex] = null; } // 通知UI更新特定槽位 UIManager.Instance.inventoryUI.UpdateSlot(slotIndex); return true; } }

注意:数据同步的黄金法则UI只负责显示和触发事件,真正的数据修改必须在InventoryManager这样的数据管理层进行。然后由数据管理层发出事件(如OnInventoryUpdated),UI层监听事件并更新显示。这种模式避免了数据不同步的噩梦。在Drop操作中,InteractionManager从状态机获取意图和槽位索引,然后调用InventoryManager.TryRemoveItemFromSlot来执行数据删除,成功后,再生成世界物品。这个顺序不能乱,否则会出现“东西丢出去了,背包里却没少”的BUG。

2.3 地面点击检测与坐标计算

玩家点击地面,我们需要获取一个准确的世界坐标来放置物品。这里通常使用射线检测(Raycast)。但《星露谷物语》是2D像素风游戏,其实现可能是基于2D物理系统或网格(Grid)的。为了更通用,我们讨论在3D场景中实现类似俯视角2D游戏的效果。

创建一个GroundClickHandler脚本,挂载在主摄像机或一个全局管理器上:

public class GroundClickHandler : MonoBehaviour { public Camera gameCamera; public LayerMask groundLayerMask; // 只检测地面的Layer void Update() { if (Input.GetMouseButtonDown(0)) { // 监听鼠标左键点击 HandleGroundClick(); } } void HandleGroundClick() { Ray ray = gameCamera.ScreenPointToRay(Input.mousePosition); RaycastHit hit; // 进行射线检测,只检测指定层 if (Physics.Raycast(ray, out hit, Mathf.Infinity, groundLayerMask)) { Vector3 dropPosition = hit.point; // 关键:询问交互管理器是否有丢弃意图 if (InteractionManager.Instance.TryGetDropIntent(out InventoryItemData itemData, out int slotIndex)) { // 执行丢弃逻辑 ExecuteDrop(itemData, slotIndex, dropPosition); } // 如果没有丢弃意图,这里可以处理其他地面交互,如移动、检查等 } } void ExecuteDrop(InventoryItemData itemData, int slotIndex, Vector3 position) { // 后续详细实现 } }

这里有几个极易踩坑的细节

  1. LayerMask设置:务必为可点击的地面(如Terrain、Plane)设置单独的Layer(如“Ground”),并在groundLayerMask中只包含这一层。否则,射线可能会击中玩家自身、树木或其他物品,导致丢弃位置错误。
  2. 坐标修正hit.point是射线与碰撞体表面的交点。如果你希望物品“放在”地面上而不是“嵌进”地里,可能需要根据物品Prefab的锚点(Pivot)高度进行微调。例如,如果物品的Pivot在底部中心,那么dropPosition可以直接使用;如果Pivot在物体中心,则需要dropPosition.y += itemPrefab.transform.localScale.y / 2
  3. 点击UI穿透:如果鼠标点击位置下有UI元素(如道具栏本身),射线检测也会触发,导致你点击背包时不小心把东西丢在脚下。解决方案是在HandleGroundClick开头加入UI射线阻断判断。Unity EventSystem提供了IsPointerOverGameObject方法。
void HandleGroundClick() { // 防止点击在UI上时触发地面操作 if (EventSystem.current.IsPointerOverGameObject()) { return; } ... // 原有的射线检测代码 }

3. 丢弃操作的完整实现流程

3.1 从数据层到场景层的生成

ExecuteDrop方法被调用时,我们手头有三样东西:要丢的物品数据itemData、来源槽位slotIndex、目标位置position。接下来的步骤必须原子化,要么全部成功,要么全部失败。

void ExecuteDrop(InventoryItemData itemData, int slotIndex, Vector3 position) { // 1. 数据验证(双重保险) if (itemData == null || itemData.quantity <= 0) { Debug.LogWarning("尝试丢弃无效物品数据。"); InteractionManager.Instance.ClearIntent(); return; } // 2. 从库存数据层移除一个物品 if (!InventoryManager.Instance.TryRemoveItemFromSlot(slotIndex, 1)) { Debug.LogError("从库存移除物品失败,丢弃操作中止。"); // 这里可以给玩家一个视觉或听觉反馈,比如播放一个错误音效 AudioManager.Instance.PlaySFX("Error"); return; } // 3. 在场景中生成物品实体 GameObject droppedItemPrefab = itemData.worldPrefab; if (droppedItemPrefab == null) { Debug.LogError($"物品 {itemData.itemName} 的 worldPrefab 未配置。"); // 即使数据移除了,生成失败也属于严重错误,应考虑回滚或给予补偿 return; } // 计算最终生成位置(添加一点高度,模拟丢弃动作) Vector3 spawnPosition = position + Vector3.up * 0.1f; GameObject worldItem = Instantiate(droppedItemPrefab, spawnPosition, Quaternion.identity); // 4. 初始化场景中的物品实体 WorldItemController worldController = worldItem.GetComponent<WorldItemController>(); if (worldController != null) { // 传递物品数据给场景实体,使其被拾取时能正确识别 worldController.Initialize(itemData.Clone(), 1); // 这里传递一个数量为1的副本 } else { // 如果Prefab上没有控制器,至少给它一个可识别的标签或名称 worldItem.name = $"Dropped_{itemData.itemName}"; } // 5. 可选:添加一点物理效果,比如一个小的向上弹跳 Rigidbody rb = worldItem.GetComponent<Rigidbody>(); if (rb != null) { rb.AddForce(Vector3.up * 2f, ForceMode.Impulse); } // 6. 提供反馈 AudioManager.Instance.PlaySFX("DropItem"); // 可以在这里播放一个粒子效果 // 7. 检查库存中是否还有该物品,如果没有,则清除丢弃意图 InventoryItemData updatedItemInSlot = InventoryManager.Instance.GetItemAtSlot(slotIndex); if (updatedItemInSlot == null || updatedItemInSlot.quantity <= 0) { InteractionManager.Instance.ClearIntent(); } else { // 如果还有,更新意图中的数据(因为quantity减少了) // 注意:这里直接更新状态机内的引用可能不安全,更好的做法是让状态机在下次TryGet时从库存重新查询 // 一个简单方案是清除意图,要求玩家重新点击物品槽选择 InteractionManager.Instance.ClearIntent(); // 或者,如果希望连续丢弃,可以重新设置意图,但必须使用更新后的数据 // InteractionManager.Instance.SetDropIntent(updatedItemInSlot, slotIndex); } Debug.Log($"成功丢弃了1个 {itemData.itemName} 在 {position}。"); }

实操心得:原子操作与回滚上面的步骤2和3是关键。务必先更新数据,再创建物体。如果顺序反过来,先创建物体但数据更新失败,场景中就会出现一个“幽灵物品”,无法被库存系统追踪。在更复杂的系统中,可以考虑引入“事务”概念,将2和3打包,失败时回滚。对于独立游戏,上述顺序加上严谨的错误检查通常足够健壮。另外,第7步关于意图更新的处理需要根据你的游戏设计来决定。是允许玩家按住Ctrl连续点击地面快速丢弃多个?还是每次丢弃后都需要重新选择物品?《星露谷物语》采用的是后者,更清晰,不易误操作。

3.2 场景物品实体的设计

生成的worldItem不是一个简单的模型,它应该是一个功能完整的交互实体。至少需要挂载以下组件:

  1. Collider:用于射线检测拾取。
  2. Rigidbody(如果是3D):实现物理下落和静止。记得勾选Is Kinematic或合理设置质量,防止物品乱飞。
  3. WorldItemController脚本:核心控制器。
public class WorldItemController : MonoBehaviour { public InventoryItemData itemData; public int quantity = 1; private bool _isBeingPickedUp = false; public void Initialize(InventoryItemData data, int qty) { itemData = data; quantity = qty; // 可以根据数据更新外观,比如不同的矿石有不同的模型 } // 当玩家与物品交互时调用(例如,按下“拾取”键或走到旁边自动拾取) public void OnInteract() { if (_isBeingPickedUp) return; _isBeingPickedUp = true; // 通知库存管理器添加物品 bool addedSuccessfully = InventoryManager.Instance.TryAddItem(itemData, quantity); if (addedSuccessfully) { // 拾取成功,销毁实体或播放消失动画 AudioManager.Instance.PlaySFX("PickupItem"); Destroy(gameObject); } else { // 背包已满,拾取失败 AudioManager.Instance.PlaySFX("Error"); // 给玩家一个提示,比如UI飘字“背包已满” UIManager.Instance.ShowFloatingText("背包已满!", transform.position); _isBeingPickedUp = false; } } // 高亮显示等方法 void OnMouseEnter() { // 鼠标悬停时高亮,提示玩家这是一个可拾取物品 } }

3.3 UI道具栏与状态机的联动

最后,我们闭环整个流程,看看道具栏UI的Slot脚本如何与状态机交互。

public class InventorySlotUI : MonoBehaviour { public Image iconImage; public Text quantityText; private int _slotIndex; private InventoryItemData _assignedItemData; public void SetupSlot(int index) { _slotIndex = index; GetComponent<Button>().onClick.AddListener(OnSlotClicked); } public void UpdateSlot(InventoryItemData itemData) { _assignedItemData = itemData; if (itemData != null && itemData.quantity > 0) { iconImage.sprite = itemData.icon; iconImage.enabled = true; quantityText.text = itemData.quantity > 1 ? itemData.quantity.ToString() : ""; } else { // 空槽位 iconImage.enabled = false; quantityText.text = ""; _assignedItemData = null; } } void OnSlotClicked() { if (_assignedItemData != null) { // 通知交互管理器:玩家意图丢弃此槽位的物品 InteractionManager.Instance.SetDropIntent(_assignedItemData, _slotIndex); // 同时,UI上可以给这个Slot一个“已选中”的视觉反馈,比如加个边框 SetSelectedVisual(true); } } // 当其他Slot被选中或意图清除时,需要取消视觉反馈 public void SetSelectedVisual(bool isSelected) { // 控制一个高亮边框Image的显示 } }

这里有一个重要的用户体验细节:当玩家点击一个物品槽选择要丢弃的物品后,应该有一个清晰的视觉反馈(比如Slot边框变红或闪烁),同时鼠标光标可能变成一个“丢弃”图标。这明确告知了玩家当前游戏模式。当丢弃操作完成或玩家按ESC取消时,InteractionManager.ClearIntent()会被调用,它需要负责通知所有UI Slot取消高亮状态。这可以通过一个事件来实现,例如OnDropIntentCleared,让每个Slot监听并更新自身状态。

4. 性能优化、问题排查与扩展思考

4.1 对象池优化物品生成

如果你预计玩家会频繁丢弃大量同类物品(比如割草掉落的纤维),频繁的InstantiateDestroy可能引发GC(垃圾回收)卡顿。这时,对象池是必备优化手段。

public class WorldItemPool : MonoBehaviour { public static WorldItemPool Instance; public GameObject itemPrefab; // 基础物品Prefab public int initialPoolSize = 20; private Queue<GameObject> _pool = new Queue<GameObject>(); void Awake() { Instance = this; InitializePool(); } void InitializePool() { for (int i = 0; i < initialPoolSize; i++) { CreateNewPooledItem(); } } GameObject CreateNewPooledItem() { GameObject obj = Instantiate(itemPrefab); obj.SetActive(false); obj.transform.SetParent(this.transform); // 统一管理 _pool.Enqueue(obj); return obj; } public GameObject GetItemFromPool(Vector3 position, Quaternion rotation) { if (_pool.Count == 0) { CreateNewPooledItem(); } GameObject obj = _pool.Dequeue(); obj.transform.SetPositionAndRotation(position, rotation); obj.SetActive(true); return obj; } public void ReturnItemToPool(GameObject obj) { obj.SetActive(false); _pool.Enqueue(obj); } }

ExecuteDrop方法中,生成物品的步骤改为:

// GameObject worldItem = Instantiate(droppedItemPrefab, spawnPosition, Quaternion.identity); GameObject worldItem = WorldItemPool.Instance.GetItemFromPool(spawnPosition, Quaternion.identity); WorldItemController controller = worldItem.GetComponent<WorldItemController>(); controller.Initialize(itemData.Clone(), 1); // 注意:需要在WorldItemController的OnInteract中,拾取后调用WorldItemPool.Instance.ReturnItemToPool(this.gameObject);

4.2 常见问题排查表

在开发测试中,你几乎一定会遇到下面这些问题。这里给出排查思路:

问题现象可能原因排查步骤与解决方案
点击地面无反应1. 射线检测未命中。
2. 点击被UI阻断。
3.InteractionManager状态未设置。
1. 检查groundLayerMask是否正确,地面物体是否有Collider且Layer匹配。用Debug.DrawRay可视化射线。
2. 检查EventSystem.current.IsPointerOverGameObject()是否在UI点击时过早返回。
3. 在OnSlotClickedSetDropIntent方法内添加Debug.Log,确认链路通畅。
物品丢出去了,但背包数量没减数据更新顺序错误或未更新。确保执行顺序:先调用InventoryManager.TryRemoveItemFromSlot,成功后再生成场景物体。检查TryRemoveItemFromSlot内部逻辑是否正确更新了数据并通知了UI。
物品生成位置在空中或地下坐标计算错误。检查hit.point的准确性。考虑在生成前,从dropPosition向下再发一条短射线,确保物品放置在碰撞体表面。调整物品Prefab的Pivot点。
连续丢弃时,丢一个后意图就清除了意图清除逻辑有误。检查ExecuteDrop方法最后的意图更新逻辑。如果你想支持连续丢弃,不要在每次丢弃后都ClearIntent(),而是更新状态机内的物品数据为新的数量。更稳妥的设计是,每次丢弃后都要求玩家重新点击物品选择,避免误操作。
拾取丢弃的物品后,背包数据错乱WorldItemController初始化或拾取逻辑错误。确保Initialize方法传递的是itemData.Clone(),而不是原引用,防止多个场景物品共享同一个数据对象。检查InventoryManager.TryAddItem的逻辑,特别是堆叠合并部分。

4.3 功能扩展与进阶设计

基础功能实现后,可以考虑以下扩展,让系统更专业:

  1. 丢弃数量选择:类似《星露谷物语》,右键点击物品槽可以弹出滑块,选择要丢弃的具体数量。这需要在SetDropIntent时传入数量参数,并在ExecuteDropTryRemoveItemFromSlot中使用该数量。
  2. 丢弃预览与合法性检查:在鼠标悬停地面时,显示一个物品的透明预览模型。同时可以检查丢弃位置是否合法(如水中、墙上不能丢)。
  3. 网络同步(联机游戏):如果游戏支持多人,那么丢弃操作必须是一个网络RPC命令。InteractionManager在发起丢弃请求时,需要调用[Command]方法通知服务器,由服务器权威地执行数据扣除和在所有客户端生成物品实体,彻底杜绝作弊。
  4. 与任务系统联动:丢弃特定物品可能触发任务更新(如“丢弃10个杂草”)。可以在InventoryManager.TryRemoveItemFromSlot成功扣除后,抛出一个OnItemDropped事件,让任务系统监听。

实现“单击Drop项目”是深入理解Unity游戏数据流和对象生命周期管理的绝佳练习。它看似是一个小功能,却串联了UI、输入、数据、物理、资源管理等多个模块。把这里的每一步想清楚、做稳健,对你构建更复杂的游戏系统,比如建造、合成、装备等,会有极大的帮助。记住,好的代码不是一次写成的,而是根据测试反馈反复打磨出来的。多玩几次自己的游戏,感受一下丢弃操作的“手感”,你会发现很多优化点。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/8/10 3:07:18

HGDB索引膨胀检测与优化实践指南

1. HGDB索引膨胀问题概述在数据库运维工作中&#xff0c;索引膨胀是一个常见但容易被忽视的性能杀手。HGDB&#xff08;HighGo Database&#xff09;作为一款企业级关系型数据库&#xff0c;同样面临这个典型问题。当表中的数据经过频繁更新、删除操作后&#xff0c;索引页会出…

作者头像 李华
网站建设 2026/8/10 3:07:11

SpringBoot+Vue篮球联盟管理系统开发实践

1. 项目概述&#xff1a;篮球联盟管理系统的技术架构与核心价值这个基于SpringBootVueMyBatisMySQL的篮球联盟管理系统&#xff0c;是我去年为本地业余篮球联赛开发的一套完整解决方案。系统采用前后端分离架构&#xff0c;前端使用Vue 3组合式API开发&#xff0c;后端基于Spri…

作者头像 李华
网站建设 2026/8/10 3:07:10

JDK17源码编译指南:从定制到优化

1. 为什么需要自己编译JDK17&#xff1f;在开始之前&#xff0c;我们先聊聊为什么要自己编译JDK。虽然Oracle和各大厂商都提供了预编译好的JDK二进制包&#xff0c;但自己动手编译有几个不可替代的优势&#xff1a;深度定制&#xff1a;你可以根据需求启用/禁用特定功能模块&am…

作者头像 李华
网站建设 2026/8/10 3:04:49

SQL数据库操作与优化实战指南

1. SQL基础概念与核心价值 SQL&#xff08;Structured Query Language&#xff09;作为关系型数据库的标准查询语言&#xff0c;已经存在了近50年却依然保持着强大的生命力。我第一次接触SQL是在2008年处理一个客户订单系统时&#xff0c;当时就被它简洁而强大的数据操作能力所…

作者头像 李华
网站建设 2026/8/10 3:03:32

如何设计高质量的第二次编程作业

1. 项目概述"第二次作业1"这个标题看似简单&#xff0c;实际上包含了教学场景中的典型需求。作为一名教育工作者&#xff0c;我经常需要设计这种序列化的作业任务。这类编号作业通常出现在编程、数学、工程设计等需要分阶段完成的课程中。在实际教学中&#xff0c;&q…

作者头像 李华
网站建设 2026/8/10 3:02:10

从Kimi K3事件看AI模型评估:沙箱逃逸原理与安全实战

最近在AI圈子里&#xff0c;一个关于“Kimi K3模型在沙箱环境中读取基准测试答案”的讨论引起了不小的波澜。这起事件不仅触及了AI模型评估的公平性核心&#xff0c;更将“沙箱逃逸”这个在安全领域耳熟能详的概念&#xff0c;推到了大模型能力测试的前台。对于从事AI开发、模型…

作者头像 李华