基于ScriptableObject设计游戏数据表

前言

本篇文章是针对之前对于ScriptableObject概念讲解的实际应用之一,在游戏开发中,我们可以使用该类来设计编辑器时的可读写数据表或者运行时的只读数据表。本文将针对运行时的只读数据表的应用进行探索,并且结合自定义的本地持久化存储方式使得基于ScriptableObject开发的数据表能够在运行时进行读写。

代码

代码目录结构
  • Table
    • Base
    • Editor
    • Interface
    • Unit

Table则为本模块的根目录,存储各个游戏数据表的脚本,Base目录存储数据表和游戏表基类,Editor目录存储数据表的编辑器脚本,Interface目录存储数据表和数据单元接口,Unit目录存储数据单元。

Base目录

BaseTable.cs

cs 复制代码
using System;
using UnityEngine;

/// <summary>
/// 基础表
/// </summary>
public abstract class BaseTable : ScriptableObject
{
    /// <summary>
    /// 表类型
    /// </summary>
    public abstract Type mType { get; }
}

GameTable.cs

cs 复制代码
using System;
using System.Collections.ObjectModel;
using System.IO;
using System.Text;
using UnityEngine;

/// <summary>
/// 游戏表
/// </summary>
/// <typeparam name="T0">表类型</typeparam>
/// <typeparam name="T1">表单元类型</typeparam>
public class GameTable<T0, T1> : BaseTable, ITableHandler<T0, T1>
where T0 : GameTable<T0, T1>
where T1 : ITableUnit
{
    [Tooltip("是否自动控制加载和保存")] public bool isAutoControl = true;
    [HideInInspector, SerializeField] protected T1[] units;

#if UNITY_EDITOR
#pragma warning disable CS0414
    [HideInInspector, SerializeField] bool isAutoSave = true;
#pragma warning restore CS0414
#endif

    public sealed override Type mType => typeof(T0);

    public ReadOnlyCollection<T1> mUnits => Array.AsReadOnly(wrapper.value);

    public int mCount => wrapper.value == null ? 0 : wrapper.value.Length;

    public event Action<T1> mModifiedCallback;

    protected string jsonPath;

    protected TempWrapper<T1[]> wrapper;

    /// <summary>
    /// 保存到本地
    /// </summary>
    public virtual void SaveLocally()
    {
        if (Application.isEditor) return;

        wrapper.UnWrapByBinary(ref units);
        string jsonStr = JsonUtility.ToJson(this);

        if (!string.IsNullOrEmpty(jsonStr))
        {
            string dirPath = Path.GetDirectoryName(jsonPath);
            if (!Directory.Exists(dirPath)) Directory.CreateDirectory(dirPath);
            if (!File.Exists(jsonPath)) File.Create(jsonPath).Dispose();

            using (FileStream fs = new FileStream(jsonPath, FileMode.OpenOrCreate, FileAccess.Write))
            {
                byte[] bytes = Encoding.UTF8.GetBytes(jsonStr);
                fs.Write(bytes, 0, bytes.Length);
                fs.Flush();
                fs.Close();
            }
        }
    }

    /// <summary>
    /// 从本地加载
    /// </summary>
    public virtual void LoadFromLoacl()
    {
        if (Application.isEditor) return;

        if (File.Exists(jsonPath))
        {
            using (TextReader tr = new StreamReader(jsonPath, Encoding.UTF8))
            {
                string jsonStr = tr.ReadToEnd();

                if (!string.IsNullOrEmpty(jsonStr))
                {
                    try
                    {
                        JsonUtility.FromJsonOverwrite(jsonStr, this);
                        int len = units.Length;
                        wrapper.value = new T1[len];
                        units.CopyTo(wrapper.value, 0);
                        InvokeModifiedEvents();
                    }
                    catch (Exception e)
                    {
                        LogUtility.Log(e.Message, LogType.Error);
                    }
                }
                tr.Close();
            }
        }
        else
        {
            string dirPath = Path.GetDirectoryName(jsonPath);
            if (!Directory.Exists(dirPath)) Directory.CreateDirectory(dirPath);
            if (!File.Exists(jsonPath)) File.Create(jsonPath).Dispose();
        }
    }

    public virtual void ShareUnitsWith(T1[] array)
    {
        int len = wrapper.value.Length;

        if (array == null || array.Length != len)
            array = new T1[len];

        for (int i = 0; i < len; i++)
        {
            array[i] = wrapper.value[i];
        }
    }

    public virtual void SetDefault()
    {
        T0 table = Resources.Load<T0>(GamePathUtility.GetTableResourcesPath<T0>());

        if (table != null)
        {
            int len = table.units.Length;
            wrapper.value = new T1[len];
            table.units.CopyTo(wrapper.value, 0);
            InvokeModifiedEvents();
        }
    }

    public virtual T1 Get(Func<T1, bool> logic)
    {
        if (logic == null) return default;

        int len = wrapper.value.Length;
        for (int i = 0; i < len; i++)
        {
            ref T1 unit = ref wrapper.value[i];
            if (logic(unit)) return unit;
        }

        return default;
    }

    public virtual T1 Get(int index)
    {
        int len = wrapper.value.Length;
        if (index < 0 || index >= len) return default;
        return wrapper.value[index];
    }

    public virtual void Set(Func<T1, T1> logic)
    {
        if (logic == null) return;

        int len = wrapper.value.Length;
        for (int i = 0; i < len; i++)
        {
            wrapper.value[i] = logic(wrapper.value[i]);
        }
        InvokeModifiedEvents();
    }

    void InvokeModifiedEvents()
    {
        if (mModifiedCallback != null)
        {
            int len = wrapper.value.Length;
            for (int i = 0; i < len; i++)
            {
                mModifiedCallback.Invoke(wrapper.value[i]);
            }
        }
    }

    void Awake()
    {
        jsonPath = Path.Combine(Application.dataPath, $"Json/{mType.Name}.json");
    }

    void OnEnable()
    {
        if (units == null) units = Array.Empty<T1>();
        if (wrapper == null) wrapper = TempWrapper<T1[]>.WrapByBinary(ref units);
        if (isAutoControl) LoadFromLoacl();
    }

    void OnDisable()
    {
        if (isAutoControl) SaveLocally();

        if (wrapper != null)
        {
            wrapper.Dispose();
            wrapper = null;
        }
    }
}
Interface目录

ITableHandler.cs

cs 复制代码
using System;
using System.Collections.ObjectModel;

// 表处理接口
public interface ITableHandler<TTable, TUnit> where TTable : BaseTable where TUnit : ITableUnit
{
    /// <summary>
    /// 表单元合集的只读视图
    /// </summary>
    ReadOnlyCollection<TUnit> mUnits { get; }

    /// <summary>
    /// 表单元合集中元素个数
    /// </summary>
    int mCount { get; }

    /// <summary>
    /// 表单元合集更改回调
    /// </summary>
    event Action<TUnit> mModifiedCallback;

    /// <summary>
    /// 分享表单元合集给指定的数组变量
    /// </summary>
    /// <param name="array">指定的数组变量</param>
    void ShareUnitsWith(TUnit[] array);

    /// <summary>
    /// 设置为默认值
    /// </summary>
    void SetDefault();

    /// <summary>
    /// 获取表单元
    /// </summary>
    /// <param name="logic">获取逻辑</param>
    TUnit Get(Func<TUnit, bool> logic);

    /// <summary>
    /// 获取表单元
    /// </summary>
    /// <param name="index">索引</param>
    TUnit Get(int index);

    /// <summary>
    /// 修改表单元
    /// </summary>
    /// <param name="logic">修改逻辑</param>
    void Set(Func<TUnit, TUnit> logic);
}

ITableUnit.cs

cs 复制代码
// 表单元接口
public interface ITableUnit { }
Editor目录

GameTableEditor.cs

cs 复制代码
using UnityEditor;
using UnityEngine;

// 游戏表编辑器
public class GameTableEditor : Editor
{
    protected SerializedProperty units, isAutoSave;
    const string tip = "Should be saved after modification. Everything will be saved when we leave the inspector unless you don't check 'Is Auto Save'. In runtime, everything will be loaded from local in 'OnEnable' and saved to local in 'OnDisable' unless you don't check 'Is Auto Control'.";

    protected void Init()
    {
        units = serializedObject.FindProperty("units");
        isAutoSave = serializedObject.FindProperty("isAutoSave");
    }

    protected void SaveGUI()
    {
        if (GUILayout.Button("Save")) Save();
        isAutoSave.boolValue = EditorGUILayout.Toggle(isAutoSave.displayName, isAutoSave.boolValue);
    }

    protected void TipGUI()
    {
        EditorGUILayout.HelpBox(tip, MessageType.Info);
    }

    protected virtual void Save() { }

    void OnDisable() { if (isAutoSave.boolValue) Save(); }
}
示例(鼠标样式表)

CursorStyleUIUnit.cs

cs 复制代码
using System;
using UnityEngine;
using UnityEngine.UI;

// 鼠标样式UI单元
[Serializable]
public class CursorStyleUIUnit
{
    [Tooltip("鼠标样式类型")] public CursorStyleType styleType;
    [Tooltip("Dropdown组件")] public Dropdown dropdown;
    [Tooltip("当前选项的Image组件")] public Image showImage;
    [Tooltip("Dropdown组件选项模板下自定义的Image组件")] public Image itemShowImage;
}

CursorStyleUnit.cs

cs 复制代码
using System;
using UnityEngine;

// 鼠标样式单元
[Serializable]
public struct CursorStyleUnit : ITableUnit
{
    [Tooltip("鼠标样式的属性名称")] public string key;
    [Tooltip("鼠标样式的属性值")] public string value;

    public CursorStyleUnit(string key, string value)
    {
        this.key = key;
        this.value = value;
    }
}

CursorStyleTable.cs

cs 复制代码
using UnityEngine;

// 鼠标样式单元存储表
[CreateAssetMenu(fileName = "Assets/Resources/Tables/CursorStyleTable", menuName = "Custom/Create CursorStyle Table", order = 1)]
public sealed class CursorStyleTable : GameTable<CursorStyleTable, CursorStyleUnit>
{
    [HideInInspector, SerializeField] CursorShape defaultShape;
    [HideInInspector, SerializeField] CursorColor defaultColor;
    [HideInInspector, SerializeField] int defaultSize;

    /// <summary>
    /// 默认鼠标形状
    /// </summary>
    public CursorShape mDefaultShape => defaultShape;

    /// <summary>
    /// 默认鼠标颜色
    /// </summary>
    public CursorColor mDefaultColor => defaultColor;

    /// <summary>
    /// 默认鼠标尺寸
    /// </summary>
    public int mDefaultSize => defaultSize;
}

CursorStyleTableEditor.cs

cs 复制代码
using System;
using System.Collections.Generic;
using System.Linq;
using UnityEditor;
using UnityEditorInternal;
using UnityEngine;

[CustomEditor(typeof(CursorStyleTable))]
public sealed class CursorStyleTableEditor : GameTableEditor
{
    SerializedProperty defaultShape, defaultColor, defaultSize;
    ReorderableList list;
    string[] styleTypes; // 样式类型合集
    Dictionary<int, Style> styles; // key表示该项在整个集合中的索引,value表示样式
    Style defaultShapeStyle, defaultColorStyle, defaultSizeStyle; // 样式默认值
    GUIContent defaultShapeContent, defaultColorContent, defaultSizeContent;
    string[] shapeDisplayNames, colorDisplayNames, sizeDisplayNames; // 样式默认值下拉菜单选项
    int _shapeIndex, _colorIndex, _sizeIndex; // 样式默认值所选菜单项索引
    bool isStylesDirty;

    int shapeIndex
    {
        get => _shapeIndex;
        set
        {
            if (_shapeIndex != value)
            {
                _shapeIndex = value;
                UpdateDefaultStyles(Array.FindIndex(styleTypes, t => t == CursorStyleConstant.SHAPE));
            }
        }
    }

    int colorIndex
    {
        get => _colorIndex;
        set
        {
            if (_colorIndex != value)
            {
                _colorIndex = value;
                UpdateDefaultStyles(Array.FindIndex(styleTypes, t => t == CursorStyleConstant.COLOR));
            }
        }
    }

    int sizeIndex
    {
        get => _sizeIndex;
        set
        {
            if (_sizeIndex != value)
            {
                _sizeIndex = value;
                UpdateDefaultStyles(Array.FindIndex(styleTypes, t => t == CursorStyleConstant.SIZE));
            }
        }
    }

    // 记录每种样式类型和值
    struct Style
    {
        public int styleTypeIndex; // 样式类型索引
        public string value; // 样式值

        public Style(int styleTypeIndex, string value)
        {
            this.styleTypeIndex = styleTypeIndex;
            this.value = value;
        }

        public bool CompareTo(ref Style other)
        {
            return styleTypeIndex == other.styleTypeIndex && value == other.value;
        }
    }

    void OnEnable()
    {
        Init();
        defaultShape = serializedObject.FindProperty("defaultShape");
        defaultColor = serializedObject.FindProperty("defaultColor");
        defaultSize = serializedObject.FindProperty("defaultSize");

        list = new ReorderableList(serializedObject, units, false, false, true, true)
        {
            drawElementCallback = DrawUnitCallback,
            onAddCallback = OnAddElement,
            onRemoveCallback = OnDelElement
        };

        styleTypes = new string[] { CursorStyleConstant.SHAPE, CursorStyleConstant.COLOR, CursorStyleConstant.SIZE };
        styles = new Dictionary<int, Style>();

        defaultShapeStyle.styleTypeIndex = Array.FindIndex(styleTypes, t => t == CursorStyleConstant.SHAPE);
        defaultShapeStyle.value = ((CursorShape)defaultShape.intValue).ToString();
        defaultColorStyle.styleTypeIndex = Array.FindIndex(styleTypes, t => t == CursorStyleConstant.COLOR);
        defaultColorStyle.value = ((CursorColor)defaultColor.intValue).ToString();
        defaultSizeStyle.styleTypeIndex = Array.FindIndex(styleTypes, t => t == CursorStyleConstant.SIZE);
        defaultSizeStyle.value = defaultSize.intValue.ToString();

        int len = units.arraySize;
        SerializedProperty element;
        for (int i = 0; i < len; i++)
        {
            element = units.GetArrayElementAtIndex(i);
            int styleTypeIndex = Array.IndexOf(styleTypes, element.FindPropertyRelative("key").stringValue);
            AddOrSetElement(i, new Style(styleTypeIndex, element.FindPropertyRelative("value").stringValue));
        }

        defaultShapeContent = new GUIContent(defaultShape.displayName, defaultShape.tooltip);
        defaultColorContent = new GUIContent(defaultColor.displayName, defaultColor.tooltip);
        defaultSizeContent = new GUIContent(defaultSize.displayName, defaultSize.tooltip);

        len = styleTypes.Length;
        for (int i = 0; i < len; i++)
        {
            UpdateDefaultDisplayNames(i);
        }

        string str = defaultShapeStyle.value;
        _shapeIndex = Array.FindIndex(shapeDisplayNames, s => s == str);
        str = defaultColorStyle.value;
        _colorIndex = Array.FindIndex(colorDisplayNames, s => s == str);
        str = defaultSizeStyle.value;
        _sizeIndex = Array.FindIndex(sizeDisplayNames, s => s == str);
    }

    void DrawUnitCallback(Rect rect, int index, bool isActive, bool isFocused)
    {
        if (index >= styles.Count) styles[index] = new Style();

        Style style = styles[index];
        rect.y += 2;
        style.styleTypeIndex = EditorGUI.Popup(new Rect(rect.x, rect.y, 80, EditorGUIUtility.singleLineHeight), style.styleTypeIndex, styleTypes);
        style.value = EditorGUI.TextField(new Rect(rect.x + 100, rect.y, rect.width - 100, EditorGUIUtility.singleLineHeight), style.value);
        UpdateStyle(ref style, index);
    }

    void OnAddElement(ReorderableList list)
    {
        ReorderableList.defaultBehaviours.DoAddButton(list);
        AddOrSetElement(list.count - 1, new Style(0, string.Empty));
    }

    void OnDelElement(ReorderableList list)
    {
        DelElement(list.index);
        ReorderableList.defaultBehaviours.DoRemoveButton(list);
    }

    void AddOrSetElement(int index, Style style)
    {
        if (style.styleTypeIndex < 0 || style.styleTypeIndex >= styleTypes.Length
        || string.IsNullOrEmpty(style.value) || index < 0 || index >= list.count) return;

        styles[index] = style;
        UpdateDefaultDisplayNames(style.styleTypeIndex);
    }

    void DelElement(int index)
    {
        Style style = styles[index];
        styles.Remove(index);
        UpdateDefaultDisplayNames(style.styleTypeIndex);
    }

    void UpdateDefaultDisplayNames(params int[] styleTypeIndexes)
    {
        if (styleTypeIndexes == null || styleTypeIndexes.Length == 0) return;

        int len = styleTypeIndexes.Length;
        var group = styles.GroupBy(kv => kv.Value.styleTypeIndex);
        string CONST_STR;
        IGrouping<int, KeyValuePair<int, Style>> temp;

        for (int i = 0; i < len; i++)
        {
            int index = styleTypeIndexes[i];
            if (index < 0 || index >= styleTypes.Length) continue;
            CONST_STR = styleTypes[index];

            switch (CONST_STR)
            {
                case CursorStyleConstant.SHAPE:
                    temp = group.Where(g => g.Key == index).FirstOrDefault();
                    if (temp != null) shapeDisplayNames = temp.Select(kv => kv.Value.value).ToArray();
                    else shapeDisplayNames = Array.Empty<string>();
                    break;
                case CursorStyleConstant.COLOR:
                    temp = group.Where(g => g.Key == index).FirstOrDefault();
                    if (temp != null) colorDisplayNames = temp.Select(kv => kv.Value.value).ToArray();
                    else colorDisplayNames = Array.Empty<string>();
                    break;
                case CursorStyleConstant.SIZE:
                    temp = group.Where(g => g.Key == index).FirstOrDefault();
                    if (temp != null) sizeDisplayNames = temp.Select(kv => kv.Value.value).ToArray();
                    else sizeDisplayNames = Array.Empty<string>();
                    break;
            }
        }
    }

    void UpdateDefaultStyles(params int[] styleTypeIndexes)
    {
        if (styleTypeIndexes == null || styleTypeIndexes.Length == 0) return;

        int len = styleTypeIndexes.Length;
        string CONST_STR;

        for (int i = 0; i < len; i++)
        {
            int index = styleTypeIndexes[i];
            if (index < 0 || index >= styleTypes.Length) continue;
            CONST_STR = styleTypes[index];

            switch (CONST_STR)
            {
                case CursorStyleConstant.SHAPE:
                    if (_shapeIndex < 0 || _shapeIndex >= shapeDisplayNames.Length)
                        defaultShapeStyle.value = CursorShape.None.ToString();
                    else defaultShapeStyle.value = shapeDisplayNames[_shapeIndex];
                    break;
                case CursorStyleConstant.COLOR:
                    if (_colorIndex < 0 || _colorIndex >= colorDisplayNames.Length)
                        defaultColorStyle.value = CursorColor.None.ToString();
                    else defaultColorStyle.value = colorDisplayNames[_colorIndex];
                    break;
                case CursorStyleConstant.SIZE:
                    if (_sizeIndex < 0 || _sizeIndex >= sizeDisplayNames.Length)
                        defaultSizeStyle.value = "0";
                    else defaultSizeStyle.value = sizeDisplayNames[_sizeIndex];
                    break;
            }
        }
    }

    void UpdateStyle(ref Style style, int index)
    {
        if (!styles[index].CompareTo(ref style))
        {
            styles[index] = style;
            isStylesDirty = true;
        }
    }

    public override void OnInspectorGUI()
    {
        serializedObject.Update();
        base.OnInspectorGUI();

        EditorGUILayout.LabelField("鼠标样式单元合集", EditorStyles.boldLabel);
        list.DoLayoutList();

        EditorGUILayout.LabelField("鼠标样式默认值", EditorStyles.boldLabel);

        if (isStylesDirty)
        {
            isStylesDirty = false;
            for (int i = 0; i < styleTypes.Length; i++)
            {
                UpdateDefaultDisplayNames(i);
                UpdateDefaultStyles(i);
            }
        }

        EditorGUI.BeginDisabledGroup(shapeDisplayNames.Length == 0);
        shapeIndex = EditorGUILayout.Popup(defaultShapeContent, shapeIndex, shapeDisplayNames);
        EditorGUI.EndDisabledGroup();

        EditorGUI.BeginDisabledGroup(colorDisplayNames.Length == 0);
        colorIndex = EditorGUILayout.Popup(defaultColorContent, colorIndex, colorDisplayNames);
        EditorGUI.EndDisabledGroup();

        EditorGUI.BeginDisabledGroup(sizeDisplayNames.Length == 0);
        sizeIndex = EditorGUILayout.Popup(defaultSizeContent, sizeIndex, sizeDisplayNames);
        EditorGUI.EndDisabledGroup();

        SaveGUI();
        TipGUI();
        serializedObject.ApplyModifiedProperties();
    }

    protected override void Save()
    {
        List<CursorStyleUnit> reserve = new List<CursorStyleUnit>();
        int len = styles.Count;

        for (int i = 0; i < len; i++)
        {
            Style style = styles[i];
            if (!string.IsNullOrEmpty(style.value))
            {
                CursorStyleUnit v_unit = new CursorStyleUnit(styleTypes[style.styleTypeIndex], style.value);
                if (!reserve.Contains(v_unit)) reserve.Add(v_unit);
            }
        }

        units.ClearArray();
        styles.Clear();
        len = reserve.Count;
        CursorStyleUnit unit;
        SerializedProperty element;

        for (int i = 0; i < len; i++)
        {
            units.InsertArrayElementAtIndex(i);
            element = units.GetArrayElementAtIndex(i);
            unit = reserve[i];
            element.FindPropertyRelative("key").stringValue = unit.key;
            element.FindPropertyRelative("value").stringValue = unit.value;
            styles[i] = new Style(Array.FindIndex(styleTypes, t => t == unit.key), unit.value);
        }

        for (int i = 0; i < styleTypes.Length; i++)
        {
            UpdateDefaultDisplayNames(i);
            UpdateDefaultStyles(i);
        }

        if (Enum.TryParse(defaultShapeStyle.value, out CursorShape shape))
            defaultShape.intValue = (int)shape;
        if (Enum.TryParse(defaultColorStyle.value, out CursorColor color))
            defaultColor.intValue = (int)color;
        defaultSize.intValue = Convert.ToInt32(defaultSizeStyle.value);

        serializedObject.ApplyModifiedProperties();
    }
}

界面展示

分析

BaseTable作为所有表格的抽象基类并继承自ScriptableObject,用于后续扩展。ITableHandler声明表格的公开属性和行为。ITableUnit声明数据单元的公开属性和行为,作为暂留接口用于后续扩展,所有数据单元需要实现该接口。GameTable继承自BaseTable,并实现了ITableHandler接口,作为游戏数据表的基类,实现通用属性和方法,向具体游戏表类开放重写方法。GameTableEditor作为游戏数据表编辑器脚本的基类,实现通用逻辑。


示例中CursorStyleUIUnit作为鼠标样式的UI单元,负责定义UI界面上与表格数据相对应的UI组件。CursorStyleUnit作为鼠标样式的数据单元,负责定义每一项表格数据。CursorStyleTable则是定义鼠标样式表的具体逻辑。CursorStyleTableEditor用于定义鼠标样式表在编辑器Inspector面板中的GUI界面。


GameTable中isAutoControl字段用于启用运行时自动进行本地持久化管理的服务,在OnEnable方法中从本地持久化文件中加载内容,在OnDisable方法中将缓存内容保存至本地持久化文件中。isAutoSave字段用于启用编辑器时表格自动保存修改到资产文件的服务,若不勾选,每次在Inspector面板中进行修改后需要手动点击Save按钮进行保存,勾选后会自动保存。提供了指示表类型、表单元只读视图、表单元个数和修改回调等属性,以及本地持久化管理、表单元共享、表单元获取和设置以及重置为默认值等方法。


对表格进行设计后,我们可以使用表格管理器来统一管理所有表格,基于ScriptableObject的特性,我们可以为每个表格创建资产文件,通过加载资产文件即可获取表格实例。


TempWrapper称为字段临时缓存包装器,具体请看系列文章中与此相关的内容。

版本改进

......

系列文章

字段临时缓存包装器

如果这篇文章对你有帮助,请给作者点个赞吧!

相关推荐
半盏茶香38 分钟前
【C语言】分支和循环详解(下)猜数字游戏
c语言·开发语言·c++·算法·游戏
逐·風6 小时前
unity关于自定义渲染、内存管理、性能调优、复杂物理模拟、并行计算以及插件开发
前端·unity·c#
_oP_i7 小时前
Unity Addressables 系统处理 WebGL 打包本地资源的一种高效方式
unity·游戏引擎·webgl
PandaQue8 小时前
《怪物猎人:荒野》游戏可以键鼠直连吗
游戏
白狐欧莱雅13 小时前
使用python中的pygame简单实现飞机大战游戏
经验分享·python·游戏·pygame
豆本-豆豆奶14 小时前
用 Python 写了一个天天酷跑(附源码)
开发语言·python·游戏·pygame·零基础教程
Leoysq16 小时前
【UGUI】实现点击注册按钮跳转游戏场景
游戏·unity·游戏引擎·ugui
_oP_i20 小时前
unity中 骨骼、纹理和材质关系
unity·游戏引擎·材质
Padid1 天前
Unity SRP学习笔记(二)
笔记·学习·unity·游戏引擎·图形渲染·着色器
Tp_jh1 天前
推荐一款非常好用的C/C++在线编译器
linux·c语言·c++·ide·单片机·unity·云原生