基于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称为字段临时缓存包装器,具体请看系列文章中与此相关的内容。

版本改进

......

系列文章

字段临时缓存包装器

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

相关推荐
向宇it2 小时前
【unity进阶知识6】Resources的使用,如何封装一个Resources资源管理器
开发语言·游戏·unity·游戏引擎
Lossya4 小时前
【python实操】python小程序之过七游戏以及单词单复数分类
开发语言·python·游戏·小程序
luluvx4 小时前
LeetCode[中等] 55.跳跃游戏
算法·leetcode·游戏
dangoxiba6 小时前
[Unity Demo]从零开始制作空洞骑士Hollow Knight第十一集:制作法术系统的回血机制和火球机制
unity·游戏引擎
Thomas_YXQ7 小时前
Unity3D Shader的阴影部分法线效果详解
开发语言·游戏·unity·架构·unity3d
朗迹 - 张伟7 小时前
Unity 2D RPG Kit 学习笔记
笔记·学习·unity
氦客8 小时前
Unity3D入门(四) : Android和Unity3D交互 - Unity调用Android
android·unity·交互·unity3d·调用·javaobject·javaclass
tealcwu8 小时前
【软件工程】模块化思想概述
unity·游戏引擎·软件工程
沐沐森的故事13 小时前
在Unity编辑器中实现组件的复制与粘贴:完整指南
unity·编辑器·组件删除·组件复制·组件粘贴·copy component·paste component