IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> 游戏开发 -> 使用Entitas构建游戏框架(一) -> 正文阅读

[游戏开发]使用Entitas构建游戏框架(一)

前言

解耦在编程时是很重要的一个考量因素,但是如果能在框架上就增加约束,让代码看上去更清晰,那么即便有一些新手,在这种约束下,也不会耦合太严重。暴雪在GDC 2017年分享了《守望先锋》关于ECS系统的实施方案,给出了一种框架上解耦的答案。虽然这种面向数据编程的思想其实很早就有了,但是《守望先锋》成功让ECS模式更广为人知。
Entitas其实早在《守望先锋》之前就已经有了,是一个超快的实体组件系统框架(ECS),专门为c#和Unity制作。内部缓存和惊人的快速组件访问使它成为首屈一指的框架。


一、为什么要ECS

在ECS之前,大部分游戏框架都是基于面向对象的,面向对象有个特点就是封装,即把一个对象的相关的属性以及对象的行为方法放到一起。大多数游戏都有主角,在使用面向对象编程时,一般游戏主角的class都有,属性上百个,方法上百个,更是能达到上万行的代码,这就导致一个新来的程序员看到后,很难阅读,访问class的方法也散布到游戏的各个地方,耦合在了各个模块里。如下图所示,存在大量的otherClass调用MainCharacter的方法以及访问属性,代码散布到各个otherClass里面,在清理时也面临复杂的引用链难以处理的问题。
在这里插入图片描述
而ECS不同,ECS将面向对象中的属性跟方法分离,属性放在各个component里面,这些component包含在一个Entity里面,而方法被封装成一个一个的system,这些system处理拥有相同一些component的一组Enetity。这样找属性就去找component,找逻辑就去看相关的system,这就不需要去遍历一个上万行的文件,是多么轻松的一件事。如下图所示,整个游戏是一个world,包含一组system以及所有的entity,每个entity是部分component的集合。这样清晰的结构,即便写出一些耦合的代码,也只在某个system或者某个组件里,写功能的人不需要去阅读所有的逻辑代码以及去遍历component,在清理对象的时候,由于关系链清晰,因此也变得很容易。
??????在这里插入图片描述

二、Entitas介绍

Entitas是一个开源的,轻量级的,超快的实体组件系统框架(ECS),专门为c#和Unity制作。内部缓存和惊人的快速组件访问使它成为首屈一指的框架。

源码地址:https://github.com/sschmid/Entitas-CSharp


源码介绍与使用:

先给出github上entitas的图说明:
在这里插入图片描述

1. Component

Entitas中,所有的逻辑属性都要求保存在Component中,在Entitas中所有声明的component都要继承IComponent接口,而Component除了一些功能型的util方法以外,是不能有逻辑代码的。

IComponent源码如下:

namespace Entitas {
    public interface IComponent {
    }
}

这里我们需要添加一个interface:IResetable,原因是component都是池化对象,防止dirty:

namespace Entitas
{
	public interface IResetable
	{
		void Reset();
	}
}

因此添加一个抽象类:

using Entitas;

namespace SHH.Share.Component
{
    public abstract class BaseComponent : IComponent, IResetable
    {
        public virtual void Reset()
        {
        }
    }
}

我们声明一个组件可以像下面一样:

using SHH.Math;
using Entitas;
using SHH.Share.Component;

namespace SHH
{
    public partial class PlayerPositionComponent : BaseComponent
    {
        public float3 Position;

        public override void Reset()
        {
            base.Reset();
            Position = float3.zero;
        }
    }

    public partial class PlayerAimRotComponent : BaseComponent
    {
        public float3 AimRotation;

        public override void Reset()
        {
            base.Reset();
            AimRotation = float3.zero;
        }
    }
}

2. Entity

实体是存储数据的容器,用于表示应用程序中的某些对象。您可以以iccomponent的形式从实体中添加、替换或删除component。实体有相应的事件让您知道组件是否被添加、替换或删除。Entity继承了IEntity,其实这里IEntity也只有Entity继承。

IEntity部分源码如下:

    public delegate void EntityComponentChanged(
        IEntity entity, int index, IComponent component, ushort opFlags
    );

    public delegate void EntityComponentReplaced(
        IEntity entity, int index, IComponent previousComponent, IComponent newComponent, ushort opFlags
    );

    public delegate void EntityEvent(IEntity entity);

    public interface IEntity : IAERC {

        event EntityComponentChanged OnComponentAdded;
        event EntityComponentChanged OnComponentRemoved;
        event EntityComponentReplaced OnComponentReplaced;
        event EntityEvent OnEntityReleased;
        event EntityEvent OnDestroyEntity;

        int totalComponents { get; }
        int creationIndex { get; }
        bool isEnabled { get; }

        Stack<IComponent>[] componentPools { get; }
        ContextInfo contextInfo { get; }
        IAERC aerc { get; }

        void Initialize(int creationIndex,
                        int totalComponents,
                        Stack<IComponent>[] componentPools,
                        ContextInfo contextInfo = null,
                        IAERC aerc = null);

        void Reactivate(int creationIndex);

        void AddComponent(int index, IComponent component, ushort opFlags);
        void RemoveComponent(int index,ushort opFlags);
        void ReplaceComponent(int index, IComponent component, ushort opFlags);

        IComponent GetComponent(int index);
        IComponent[] GetComponents();
        int[] GetComponentIndices();

        bool HasComponent(int index);
        bool HasComponents(int[] indices);
        bool HasAnyComponent(int[] indices);

        void RemoveAllComponents();

        Stack<IComponent> GetComponentPool(int index);
        IComponent CreateComponent(int index, Type type);
        T CreateComponent<T>(int index) where T : new();

        void Destroy();
        void InternalDestroy();
        void RemoveAllOnEntityReleasedHandlers();
    }

IEntity主要描述了Entity的基本描述,比方说组件数量,component的行为,以及池化相关等

而Entity继承自IEntity,实现了上述方法,增加了字段

IComponent[] _components;

public void Initialize(int creationIndex, int totalComponents, Stack<IComponent>[] componentPools, 
	ContextInfo contextInfo = null, IAERC aerc = null) 
{
     Reactivate(creationIndex);

      _totalComponents = totalComponents;
      _components = new IComponent[totalComponents];
      _componentPools = componentPools;

      _contextInfo = contextInfo ?? createDefaultContextInfo();
      _aerc = aerc ?? new SafeAERC(this);
   }

这就是我们关键的数据组,其他的操作都是辅助_components行为的,只初始化Entity的时候,就会创建一个IComponent数组,大小由totalComponents决定。也就是说,每个entity的组件数量最开始就决定了,而每一个数组index就表示了特定的组件,这就给我们筛选相同组件的entity带来了可能。

3. Matcher

ECS中的每个System其实是对拥有相同一组component的Entity的处理,那么要匹配Entity的哪些组件呢,由Matcher指定。

Matcher有如下字段

public int[] indices {
    get {
        if (_indices == null) {
            _indices = mergeIndices(_allOfIndices, _anyOfIndices, _noneOfIndices);
        }
        return _indices;
    }
}

public int[] allOfIndices { get { return _allOfIndices; } }
public int[] anyOfIndices { get { return _anyOfIndices; } }
public int[] noneOfIndices { get { return _noneOfIndices; } }

public string[] componentNames { get; set; }

int[] _indices;
int[] _allOfIndices;
int[] _anyOfIndices;
int[] _noneOfIndices;

这些字段保存了一个Entity需要的Component index数组,其中

  • allOfIndices :表示需要每个component index代表的组件都要有
  • anyOfIndices :表示有其中的一个就行
  • noneOfIndices :表示这里面的index都没有

通过这些index进行Entity 筛选:

public bool Matches(Entity entity) {
     return (_allOfIndices == null || entity.HasComponents(_allOfIndices))
            && (_anyOfIndices == null || entity.HasAnyComponent(_anyOfIndices))
            && (_noneOfIndices == null || !entity.HasAnyComponent(_noneOfIndices));
 }

4. Group

Group支持对上下文中的实体进行超快速过滤。当实体发生变化时,它们会不断更新,并可以立即返回实体组。假设你有成千上万个实体,你只想要那些有一个PlayerPositionComponent 的实体-只要询问这个组的上下文,它已经有结果在等待你。
Group继承自IGroup,还是先看interface:

namespace Entitas {

    public delegate void GroupChanged(IGroup group, Entity entity, int index, IComponent component);
    public delegate void GroupUpdated(IGroup group, Entity entity, int index, IComponent previousComponent, IComponent newComponent);

    public interface IGroup : IEnumerable<Entity> {

        int count { get; }

        void RemoveAllEventHandlers();

		event GroupChanged OnEntityAdded;
		event GroupChanged OnEntityRemoved;
		event GroupUpdated OnEntityUpdated;

		IMatcher matcher { get; }

		void HandleEntitySilently(Entity entity);
		void HandleEntity(Entity entity, int index, IComponent component);

		GroupChanged HandleEntity(Entity entity);

		void UpdateEntity(Entity entity, int index, IComponent previousComponent, IComponent newComponent);

		bool ContainsEntity(Entity entity);

		Entity[] GetEntities();
		Entity GetSingleEntity();
	}
}

可以看到IGroup中有个matcher,一个entity的count,以及一组entity变化后的行为,在这里就应该知道,Group的主要功能是通过matcher缓存了一组Entity
下面看下Group的部分源码:

namespace Entitas {
    public class Group : IGroup {
        /// Returns the number of entities in the group.
        public int count { get { return _entities.Count; } }

        /// Returns the matcher which was used to create this group.
        public IMatcher matcher { get { return _matcher; } }

        readonly IMatcher _matcher;

        readonly HashSet<Entity> _entities = new HashSet<Entity>(
            EntityEqualityComparer.comparer
        );

        Entity[] _entitiesCache;
        Entity _singleEntityCache;
        string _toStringCache;

        /// Use context.GetGroup(matcher) to get a group of entities which match
        /// the specified matcher.
        public Group(IMatcher matcher) {
            _matcher = matcher;
        }
    }
}

可以看到,Group构造函数传进来了一个matcher,然后使用_entities保存了能通过matcher的Entity,而在OnEntityAdded,OnEntityRemoved的时候会对_entities进行更新

5. Collector

一个Collector可以从相同的Context中观察一个或多个Group,并且根据指定的groupEvent收集更改的实体。
其中groupEvent有如下事件:

public enum GroupEvent : byte {
    Added,
    Removed,
    AddedOrRemoved,
    Modified,
}

Collector部分源码:

public HashSet<Entity> collectedEntities { get { return _collectedEntities; } }
public int count { get { return _collectedEntities.Count; } }
readonly HashSet<Entity> _collectedEntities;
readonly IGroup[] _groups;
readonly GroupEvent[] _groupEvents;

public Collector(IGroup[] groups, GroupEvent[] groupEvents) {
     _groups = groups;
     _collectedEntities = new HashSet<Entity>(EntityEqualityComparer.comparer);
     _groupEvents = groupEvents;

     if (groups.Length != groupEvents.Length) {
         throw new CollectorException(
             "Unbalanced count with groups (" + groups.Length +
             ") and group events (" + groupEvents.Length + ").",
             "Group and group events count must be equal."
         );
     }

     _addEntityCache = addEntity;
     _updateEntityCache = updateEntity;
     Activate();
 }

在Collector构造函数中指定了IGroup[] groups, GroupEvent[] groupEvents,每一个group对应注册一个groupEvent。随后调用Activate方法:

public void Activate() {
     for (int i = 0; i < _groups.Length; i++) {
         var group = _groups[i];
         var groupEvent = _groupEvents[i];
         switch (groupEvent) {
             case GroupEvent.Added:
                 group.OnEntityAdded -= _addEntityCache;
                 group.OnEntityAdded += _addEntityCache;
                 break;
             case GroupEvent.Removed:
                 group.OnEntityRemoved -= _addEntityCache;
                 group.OnEntityRemoved += _addEntityCache;
                 break;
             case GroupEvent.AddedOrRemoved:
                 group.OnEntityAdded -= _addEntityCache;
                 group.OnEntityAdded += _addEntityCache;
                 group.OnEntityRemoved -= _addEntityCache;
                 group.OnEntityRemoved += _addEntityCache;
                 break;
             case GroupEvent.Modified:
                 group.OnEntityUpdated -= _updateEntityCache;
                 group.OnEntityUpdated += _updateEntityCache;
                 break;
         }
     }
 }

这样在每个groupEntity发生改变的时候,就在addEntity或者updateEntity中收集Entity到_collectedEntities中,然后就可以通过如下代码,对这些改变进行处理:

foreach (var e in collector.collectedEntities) {
    // do something with all the entities
    // that have been collected to this point of time
}
collector.ClearCollectedEntities();

6. Context

Context是创建和销毁实体的工厂。他保存了所有的entity对象以及group,可以使用它来过滤感兴趣的实体。
先看下Context继承的接口IContext:

public interface IContext {

    event ContextEntityChanged OnEntityCreated;
    event ContextEntityChanged OnEntityWillBeDestroyed;
    event ContextEntityChanged OnEntityDestroyed;

    event ContextGroupChanged OnGroupCreated;

    int totalComponents { get; }

    Stack<IComponent>[] componentPools { get; }
    ContextInfo contextInfo { get; }

    int count { get; }
    int reusableEntitiesCount { get; }
    int retainedEntitiesCount { get; }

    void DestroyAllEntities();

    void ResetCreationIndex();
    void ClearComponentPool(int index);
    void ClearComponentPools();
    void Reset();

	Entity CreateEntity(bool isTemp = false);
	
	// TODO Obsolete since 0.42.0, April 2017
	[Obsolete("Please use entity.Destroy()")]
	void DestroyEntity(Entity entity);
	
	bool HasEntity(Entity entity);
	Entity[] GetEntities();
	
	IGroup GetGroup(IMatcher matcher);
}

根据接口的内容基本可以确定这个类的作用,对entity的管理,component池化管理,group缓存。
然后看一下Context类的属性跟构造函数:

public class Context : IContext
{
	/// The total amount of components an entity can possibly have.
	/// This value is generated by the code generator,
	/// e.g ComponentLookup.TotalComponents.
	public int totalComponents { get { return _totalComponents; } }

	/// Returns all componentPools. componentPools is used to reuse
	/// removed components.
	/// Removed components will be pushed to the componentPool.
	/// Use entity.CreateComponent(index, type) to get a new or reusable
	/// component from the componentPool.
	public Stack<IComponent>[] componentPools { get { return _componentPools; } }

	/// The contextInfo contains information about the context.
	/// It's used to provide better error messages.
	public ContextInfo contextInfo { get { return _contextInfo; } }

	/// Returns the number of entities in the context.
	public int count { get { return _entities.Count; } }

	/// Returns the number of entities in the internal ObjectPool
	/// for entities which can be reused.
	public int reusableEntitiesCount { get { return _reusableEntities.Count; } }

	/// Returns the number of entities that are currently retained by
	/// other objects (e.g. Group, Collector, ReactiveSystem).
	public int retainedEntitiesCount { get { return _retainedEntities.Count; } }

	readonly int _totalComponents;

	readonly Stack<IComponent>[] _componentPools;
	readonly ContextInfo _contextInfo;
	readonly Func<IEntity, IAERC> _aercFactory;

	readonly HashSet<Entity> _entities = new HashSet<Entity>(EntityEqualityComparer.comparer);
	readonly Stack<Entity> _reusableEntities = new Stack<Entity>();
	readonly HashSet<Entity> _retainedEntities = new HashSet<Entity>(EntityEqualityComparer.comparer);

	readonly Dictionary<string, IEntityIndex> _entityIndices;
	readonly Dictionary<IMatcher, IGroup> _groups = new Dictionary<IMatcher, IGroup>();
	readonly List<IGroup>[] _groupsForIndex;
	readonly IGroup[] _groupForSingle;
	readonly ObjectPool<List<GroupChanged>> _groupChangedListPool;
	readonly Dictionary<int, Entity> _entitiesLookup = new Dictionary<int, Entity>();

	int _creationIndex;
	Entity[] _entitiesCache;
	
	/// The prefered way to create a context is to use the generated methods
	/// from the code generator, e.g. var context = new GameContext();
	public Context(int totalComponents, int startCreationIndex, ContextInfo contextInfo, Func<IEntity, IAERC> aercFactory)
	{
		_totalComponents = totalComponents;
		_creationIndex = startCreationIndex;

		if (contextInfo != null)
		{
			_contextInfo = contextInfo;
			if (contextInfo.componentTypeInfo.componentNames.Length != totalComponents)
			{
				throw new ContextInfoException(this, contextInfo);
			}
		}
		else
		{
			_contextInfo = createDefaultContextInfo();
		}

		_aercFactory = aercFactory == null
			? (entity) => new SafeAERC(entity)
			: aercFactory;

		_groupsForIndex = new List<IGroup>[totalComponents];
		_groupForSingle = new IGroup[totalComponents];
		_componentPools = new Stack<IComponent>[totalComponents];
		_entityIndices = new Dictionary<string, IEntityIndex>();

		_groupChangedListPool = new ObjectPool<List<GroupChanged>>(
									() => new List<GroupChanged>(),
									list => list.Clear()

								);

		// Cache delegates to avoid gc allocations
		_cachedEntityChanged = updateGroupsComponentAddedOrRemoved;
		_cachedComponentReplaced = updateGroupsComponentReplaced;
		_cachedEntityReleased = onEntityReleased;
		_cachedDestroyEntity = onDestroyEntity;

		// Add listener for updating lookup
		OnEntityCreated += (c, entity) => _entitiesLookup.Add(entity.creationIndex, (Entity)entity);
		OnEntityDestroyed += (c, entity) => _entitiesLookup.Remove(entity.creationIndex);
	}
}

代码虽然有点多,但是其实还是很清楚的,Context 中保存了的关键信息:

  • readonly Stack[] _componentPools; :component Pool数组,用来缓存component
  • HashSet _entities : 保存所有的entity
  • readonly Stack _reusableEntities : 保存回收的Entity,再次创建时,可以从这里取
  • readonly HashSet _retainedEntities : 保存还有引用的Entity,虽然被销毁了,但是还有引用,在DestroyAllEntities时会判断,如果有,证明之前有没有释放引用的情况。
  • readonly Dictionary<IMatcher, IGroup> _groups : 缓存了所有Matcher对应的Group,减少GC
  • readonly List[] _groupsForIndex :index表示每个组件的index,对应一个 List保存了引用这个组件的所有Group
  • Entity[] _entitiesCache : 外部访问_entities ,同时保证不会修改到_entities
  • int _creationIndex : 每个entity都有个唯一的creationIndex,在创建时指定

然后分析一下构造函数:
参数:

  • component的总数totalComponents,
  • entity标识index起始值startCreationIndex,
  • 上下文的说明信息contextInfo,
  • 以及自动entity引用计数工厂aercFactory,

然后构造函数对之前的变量进行初始化,以及对一些委托初始化,之后就可以根据Context处理了。

7. System

主要的逻辑处理的地方,遍历一组具有相同组件的Entity,所有System 都继承了ISystem

namespace Entitas {
    /// This is the base interface for all systems.
    /// It's not meant to be implemented.
    public interface ISystem {
        bool IsEnable();
        void SetEnable(bool value);
    }
}

然后可以看到ICleanupSystem,IExecuteSystem,IInitializeSystem,IReactiveSystem,ITearDownSystem这些接口继承了ISystem :
在这里插入图片描述
然后有个Systems类,可以注册这些system:

using System.Collections.Generic;

namespace Entitas {

    /// Systems provide a convenient way to group systems.
    /// You can add IInitializeSystem, IExecuteSystem, ICleanupSystem,
    /// ITearDownSystem, ReactiveS
    /// ystem and other nested Systems instances.
    /// All systems will be initialized and executed based on the order
    /// you added them.
    public class Systems : IInitializeSystem, IExecuteSystem, ICleanupSystem, ITearDownSystem {

        protected readonly List<IInitializeSystem> _initializeSystems;
        protected readonly List<IExecuteSystem> _executeSystems;
        protected readonly List<ICleanupSystem> _cleanupSystems;
        protected readonly List<ITearDownSystem> _tearDownSystems;
        protected bool m_IsEnable = true;

        /// Creates a new Systems instance.
        public Systems() {
            _initializeSystems = new List<IInitializeSystem>();
            _executeSystems = new List<IExecuteSystem>();
            _cleanupSystems = new List<ICleanupSystem>();
            _tearDownSystems = new List<ITearDownSystem>();
        }

        /// Adds the system instance to the systems list.
        public virtual Systems Add(ISystem system) {
            var initializeSystem = system as IInitializeSystem;
            if (initializeSystem != null) {
                _initializeSystems.Add(initializeSystem);
            }

            var executeSystem = system as IExecuteSystem;
            if (executeSystem != null) {
                _executeSystems.Add(executeSystem);
            }

            var cleanupSystem = system as ICleanupSystem;
            if (cleanupSystem != null) {
                _cleanupSystems.Add(cleanupSystem);
            }

            var tearDownSystem = system as ITearDownSystem;
            if (tearDownSystem != null) {
                _tearDownSystems.Add(tearDownSystem);
            }

            return this;
        }

        /// Calls Initialize() on all IInitializeSystem and other
        /// nested Systems instances in the order you added them.
        public virtual void Initialize() {
            for (int i = 0; i < _initializeSystems.Count; i++) {
                _initializeSystems[i].Initialize();
            }
        }

        /// Calls Execute() on all IExecuteSystem and other
        /// nested Systems instances in the order you added them.
        public virtual void Execute() {
            for (int i = 0; i < _executeSystems.Count; i++) {
                _executeSystems[i].Execute();
            }
        }

        /// Calls Cleanup() on all ICleanupSystem and other
        /// nested Systems instances in the order you added them.
        public virtual void Cleanup() {
            for (int i = 0; i < _cleanupSystems.Count; i++) {
                _cleanupSystems[i].Cleanup();
            }
        }

        /// Calls TearDown() on all ITearDownSystem  and other
        /// nested Systems instances in the order you added them.
        public virtual void TearDown() {
            for (int i = 0; i < _tearDownSystems.Count; i++) {
                _tearDownSystems[i].TearDown();
            }
        }

        /// Activates all ReactiveSystems in the systems list.
        public void ActivateReactiveSystems() {
            //...见源码
        }

        /// Deactivates all ReactiveSystems in the systems list.
        /// This will also clear all ReactiveSystems.
        /// This is useful when you want to soft-restart your application and
        /// want to reuse your existing system instances.
        public void DeactivateReactiveSystems() {
             //...见源码
        }

        /// Clears all ReactiveSystems in the systems list.
        public void ClearReactiveSystems() {
            //...见源码
        }

        public virtual bool IsEnable() { return m_IsEnable; }
        public virtual void SetEnable(bool value) { m_IsEnable = value; }
    }
}

三、为什么不用unity自带的ECS

  1. 这么多年了,unity的ECS还是preview版
  2. 我们ECS用于client以及server,因此逻辑核打算独立出来,不依赖unityEngine,且打算做竞技开房间类型的游戏,在服务器并不能依赖burst并发多线程去处理。

总结

这是一个系列文章,将逐步使用Entitas搭建整个游戏框架,当然对Entitas有部分改造,如去掉了TEntity泛型,全直接用Entity,后面将围绕ECS搭建整个游戏。

  游戏开发 最新文章
6、英飞凌-AURIX-TC3XX: PWM实验之使用 GT
泛型自动装箱
CubeMax添加Rtthread操作系统 组件STM32F10
python多线程编程:如何优雅地关闭线程
数据类型隐式转换导致的阻塞
WebAPi实现多文件上传,并附带参数
from origin ‘null‘ has been blocked by
UE4 蓝图调用C++函数(附带项目工程)
Unity学习笔记(一)结构体的简单理解与应用
【Memory As a Programming Concept in C a
上一篇文章      下一篇文章      查看所有文章
加:2022-05-05 11:52:21  更:2022-05-05 11:55:05 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年11日历 -2024/11/23 11:09:00-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码