大江湖-分析篇-地图设计与事件

分析篇-地图设计与事件

摘要
​本文对游戏地图的制作和事件机制进行分析

大江湖的地图设计没有使用原生的 Unity2D​瓦片地图实现,而是使用了开源的 Tiled 瓦片地图编辑工具导出 tmx​地图,再通过 SuperTiled2Unity 插件将地图导入 Unity​中使用。

Tiled map editor

Tiled 是一个简单好用的地图/关卡编辑工具,有大量的 2D 游戏使用。

Tiled 是一个 2D 关卡编辑器,帮助您开发游戏内容。它的主要功能是编辑各种形式的瓦片地图,同时还支持自由图像放置以及强大的注释功能,用于为游戏提供额外的信息。Tiled 注重通用的灵活性,同时力求保持直观易用。

就瓷砖地图而言,它支持直角瓷砖层,也支持投影等距、交错等距和交错六边形层。瓷砖集可以是包含多个瓷砖的单个图像,也可以是一组单独的图像。为了支持特定的深度伪造技术,可以通过自定义距离来偏移瓷砖和层,并配置它们的渲染顺序。

编辑瓷砖图层的主要工具是一个印章刷,可以高效地绘制和复制瓷砖区域。它还支持绘制线条和圆形。此外,还有几个选择工具和一个可以自动进行地形过渡的工具。最后,它可以根据模式匹配应用更改,以自动化您的工作的某些部分。

Tiled 还支持对象层,传统上只用于在地图上注释信息,但最近也可以用于放置图像。您可以添加矩形、点、椭圆、多边形、折线和图块对象。对象的放置不限于图块网格,对象还可以进行缩放或旋转。对象层提供了很大的灵活性,可以向您的游戏级别添加几乎任何所需的信息。

相关的资源如下:

SuperTiled2Unity

SuperTiled2Unity 插件,支持将 Tiled​工具导出的 tmx​地图导入到 unity​,它支持以下功能:

  • 对瓦片对象进行排序
  • 支持自定义属性
  • 支持扩展导入器

相关的资源如下:

地图分层

地图上的各种元素,要按照规则放置到对应的层上,才能正确的渲染出来。

打开 Assets/Scene/Scenes/Demo01/Field/Field_YangTaiFuMiao.unity​,可以看到地图分为了以下几层:

underfoot​在最底层,DynamicLayer​在最顶层,其中 top-ui/stage-top/stage-bottom/obstruct​这几层似乎没有使用,代码中没有看到相关信息

underfoot

underfoot​处于地图的最底层,主要放置了以下几种对象

地图背景图片

整个地图的背景图片都绘制在这一层,这个很好理解,图片必须放在最底层,否则所有的元素都会被它遮挡

鼠标指针

Assets\Scripts\Assembly-CSharp\MapController.cs​中,将鼠标指针的 sortingOrder​修改到了这一层

1
2
3
4
5
6
7
8
9
private void Start()
{
    this.m_SortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
        return s.name == "underfoot";
    }).sortingOrder;
    // 初始化鼠标指针
    this.m_Curcor = UnityEngine.Object.Instantiate<GameObject>(SharedData.Instance(false).m_CurcorPrefab, this.map.transform);
    this.m_Curcor.GetComponentInChildren<SpriteRenderer>().sortingOrder = this.m_SortingOrder;
}

寻路格子

点击地图,角色进行自动寻路时,会根据 A*​算法算出行走的路径 PATH​对象,角色按照这个路径行走

Assets\Scripts\Assembly-CSharp\OhPlayerController.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
private void Awake()
{
    this.m_SortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
        return s.name == "underfoot";
    }).sortingOrder;
}

private void ShowPath()
{
    for (Vector3Int num = this.endPos; !num.Equals(this.startPos); num = this.pathSave[num])
    {
        this.Path = UnityEngine.Object.Instantiate<GameObject>(this.PathPrefab, this.mapcontroller.map.transform);
        this.Path.transform.localPosition = new Vector3((float) (0x19 + (num.x * 50)), (float) (-25 - (num.y * 50)));
        this.pathObject.Add(this.Path);
        this.m_Renderer = this.Path.GetComponentInChildren<SpriteRenderer>();
        this.m_Renderer.sortingOrder = (this.m_SortingOrder > 0) ? this.m_SortingOrder : 0;
    }
    this.m_MoveProcess = this.pathObject.Count - 1;
    this.m_State = State.AStarWaiting;
}

role

所有角色/物品的实体都放在这里,但是动画效果不放在这一层,只占坑

每个实体都包含了 Box Collider 2D​,角色行走时,会触碰到同层的物品,产生事件交互

EffectLayer

所有技能/物品特效和 buff​都会默认放置在这一层,显示在角色上一层

effect

CampFire​为例,Assets/Resources/prefabs/effect/CampFire.prefab​绑定了 Assets/Scripts/Assembly-CSharp/EffectController.cs​脚本

Assets/Scripts/Assembly-CSharp/EffectController.cs​脚本加载时,会将 MeshRenderer​放置到 EffectLayer​层

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
public class EffectController : MonoBehaviour
{
    public bool loopRun;
    protected SkeletonAnimation m_Animation;

    private void Start()
    {
        base.GetComponent<MeshRenderer>().sortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
            return s.name == "EffectLayer";
        }).sortingOrder;
    }
}

buff

buff​同理,Assets/Resources/prefabs/buff/effecton_fire.prefab​绑定了 Assets/Scripts/Assembly-CSharp/BuffController.cs​脚本

脚本加载时会将动画放置到 EffectLayer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class BuffController : MonoBehaviour
{
    public bool loopRun;
    protected SkeletonAnimation m_Animation;

    private void Start()
    {
        base.GetComponent<MeshRenderer>().sortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
            return s.name == "EffectLayer";
        }).sortingOrder;
        this.m_Animation = base.GetComponent<SkeletonAnimation>();
        this.m_Animation.AnimationState.SetAnimation(0, this.m_Animation.skeleton.Data.Animations.Items[0], this.loopRun);
        if (!this.loopRun)
        {
            this.m_Animation.AnimationState.Complete += new Spine.AnimationState.TrackEntryDelegate(this.State_Complete);
        }
    }

    private void State_Complete(TrackEntry trackEntry)
    {
        UnityEngine.Object.Destroy(base.gameObject);
    }
}

DynamicLayer

role​这一层的实体动画都放置在这一层

角色

  • Assets\Scripts\Assembly-CSharp\OhPlayerController.cs
  • Assets\Scripts\Assembly-CSharp\OhNpcController.cs
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class OhPlayerController : MonoBehaviour
{
    int sortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
        return s.name == "DynamicLayer";
    }).sortingOrder;
    // 修改角色动画sortingOrder 
    this.m_Animations = base.gameObject.GetComponentsInChildren<SkeletonAnimation>(true);
    SkeletonAnimation[] animations = this.m_Animations;
    for (int i = 0; i < animations.Length; i++)
    {
        animations[i].gameObject.GetComponent<MeshRenderer>().sortingOrder = sortingOrder;
    }
    // 修改对话图标层级
    base.transform.Find("head_icon/talk/Ani").GetComponent<MeshRenderer>().sortingOrder = sortingOrder;
}

effect/buf

所有通过事件产生的实体,都会将 SkeletonAnimation.MeshRenderer​和 SpriteRenderer​放置在这一层

Assets/Scripts/Assembly-CSharp/EventController.cs

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class EventController : MonoBehaviour {

    private void Awake() {
        int sortingOrder = Enumerable.FirstOrDefault<TilemapRenderer>(UnityEngine.Object.FindObjectsOfType<TilemapRenderer>(), delegate (TilemapRenderer s) {
            return s.name == "DynamicLayer";
        }).sortingOrder;
    
        this.m_Animations = base.gameObject.GetComponentsInChildren<SkeletonAnimation>(true);
        SkeletonAnimation[] animations = this.m_Animations;
        for (num2 = 0; num2 < animations.Length; num2++)
        {
            animations[num2].gameObject.GetComponent<MeshRenderer>().sortingOrder = sortingOrder;
        }
        this.m_SpriteRenderers = base.gameObject.GetComponentsInChildren<SpriteRenderer>(true);
        SpriteRenderer[] spriteRenderers = this.m_SpriteRenderers;
        for (num2 = 0; num2 < spriteRenderers.Length; num2++)
        {
            spriteRenderers[num2].sortingOrder = sortingOrder;
        }
    }
}

事件机制

事件机制是游戏的核心,涉及以下几个模块

  • 配置表:配置事件的 ID​、触发时机、动画效果、触发效果
  • 地图:指定的位置放置 object​,配置事件 ID
  • Assets\Scripts\Assembly-CSharp\MapController.cs​:根据地图上的 object id​,查找配置表事件数据,将动画展示在地图上

以这个篝火存档为例:

配置表

这个流程涉及两个配置表,触发的 ID​为 STAGE_01_7

  • gang_e01_事件表
eventidIDSTAGE_01_7STAGE_01_7STAGE_01_7STAGE_01_7
flag存档记录 flagSTAGE_01_7STAGE_01_7_SELSTAGE_01_7_SEL_1STAGE_01_7_SEL_2
trigger触发方式AUTO-自动FOOTON-经过CLICK-点击FOLLOW-跟随CLICKFOLLOWFOLLOWFOLLOW
display展示 prefabPrefabs/Effect/Ground/STAGE_FIRE
direction方向FREE
action动作TELEPORT-传送点MOVE-移动CHAT-对话GET-获取物品VOICEOVER-对话结束CAMCTRL-摄像头移动PLAYSE-播放音效CHECK-检查条件DESTROY-销毁JOIN-加入队伍TOMB-坟墓PLAYBGM-播放背景音乐EVENT-开启关闭事件BATTLE-战斗SELECT-选择对话框INFO-消息SHOP-商店WANTED-悬赏REST-住宿PLAYCG-播放动画SAVEDATA-存档PLAYANI-播放人物动作CHANGETEAM-选择队伍FOLLOW-跟随UNFOLLOW-不再跟随THROUGH-穿过CHATS_01_CHAT_2SELECTS_01_CHAT_2_SELECT_1&S_01_CHAT_2_SELECT_2
elseaction未启用
throughaction未启用
nextflag下次事件STAGE_01_7_SELSTAGE_01_7STAGE_01_7
outputend-结束事件
elsefollow未使用
menu弹出菜单0000
online是否启用1111

  • gang_e02_对话表
chatidsideeventidchating
对话 ID0-系统player-主角其他-名字所属事件 id对话内容
S_01_CHAT_2player0惬意小火让人陷入沉思……是否记录一下现有经历?
S_01_CHAT_2_SELECT_100记录
S_01_CHAT_2_SELECT_200离开

触发 STAGE_01_7​事件,触发方式为 CLICK​(鼠标点击),事件的实体为 Prefabs/Effect/Ground/STAGE_FIRE​(篝火),动作为 CHAT​对话

地图

地图上需要添加这个事件

  • 坐标为(1525,1075)
  • Type​为 Event
  • 名称为 SAVE|STAGE_01_7​,表示事件 ID 为 STAGE_01_7

MapController

初始化事件

流程如下:

  • 从地图中取出所有 type​为 Event​的对象
  • 对象名称按 | 符号进行分割,取出后面的事件 ID
  • 根据事件 ID​从 e01​事件表读取数据
  • 初始化事件信息,如果 display​有值,则调用 UnityEngine.Resources.Load​和 UnityEngine.Object.Instantiate​加载 prefab​,并挂载 EventController​脚本到对象

完整代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
private void Start() {
    this.InitEvents();
}

private void InitEvents()
{
    foreach (SuperObject obj2 in this.getByType("Event"))
    {
        char[] separator = new char[] { '|' };
        string[] strArray = obj2.name.Split(separator);
        int num2 = 2;
        foreach (gang_e01Table.Row row in SharedData.Instance(false).e01.FindAll_eventid(strArray[1]))
        {
            if (row.online != "1")
            {
                continue;
            }

            if (row.trigger == "AUTO")
            {
                Debug.Log("Revive AUTO-FLAG [" + row.flag + "]");
                SharedData.Instance(false).FlagList[row.flag] = 0;
            }
            if (SharedData.Instance(false).FlagList[row.flag] < 2)
            {
                num2 = 0;
                break;
            }
        }


        if (num2 == 2)
        {
            continue;
        }


        Event event2 = new Event {
            name = obj2.name,
            eventid = strArray[1],
            tileobj = obj2,
            flow = 0
        };

        if (SharedData.Instance(false).BackFromOtherScene && SharedData.Instance(false).MapUnitsBefore.ContainsKey(obj2.name))
        {
            event2.originevdata = SharedData.Instance(false).EventsRecord[obj2.name].originevdata;
            event2.evdata = SharedData.Instance(false).EventsRecord[obj2.name].evdata;
            event2.flow = SharedData.Instance(false).EventsRecord[obj2.name].flow;
            event2.elseroute = SharedData.Instance(false).EventsRecord[obj2.name].elseroute;
            if (SharedData.Instance(false).EventsRecord[obj2.name].display != "")
            {
                GameObject original = (GameObject) UnityEngine.Resources.Load(SharedData.Instance(false).EventsRecord[obj2.name].display);
                event2.obj = UnityEngine.Object.Instantiate<GameObject>(original, this.map.transform.parent);
                EventController local1 = event2.obj.AddComponent<EventController>();
                local1.BackFromOtherScene = SharedData.Instance(false).BackFromOtherScene;
                local1.tileName = event2.tileobj.m_TiledName;
                local1.name = obj2.name;
                local1.trigger = event2.originevdata.trigger;
                local1.display = SharedData.Instance(false).EventsRecord[obj2.name].display;
                local1.Init();
                if (SharedData.Instance(false).EventsRecord[obj2.name].objcamarectrl)
                {
                    this.EventTakeCamera(event2, true);
                }
            }
        }

        if (event2.flow != 2)
        {
            this.events.Add(obj2.name, event2);
        }
      
    }
}

检测鼠标形状

鼠标移动时,判断 event​的 action​,此处为 CHAT​,鼠标形状为 m_Talk_Pointer

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
private void FixedUpdate()
{
    if (this.InitOK && !this.IsTeleport)
    {
        this.PointerCheck();
    }
}

private void PointerCheck()
{
    // 遍历事件
    foreach (Event event2 in this.events.Values)
    {
        char[] separator = new char[] { '|' };
        string[] strArray = event2.evdata.action.Split(separator);
        string[] strArray = event2.evdata.action.Split(separator);
        // 拾取物品
        if (strArray[0] == "GET")
        {
            texture = this.m_Loot_Pointer;
        }
        // 对话
        else if (strArray[0] == "CHAT")
        {
            texture = this.m_Talk_Pointer;
        }
        // 悬赏
        else if (strArray[0] == "WANTED")
        {
            texture = this.m_Wanted_Pointer;
        }
        else
        {
            texture = this.m_Talk_Pointer;
        }
    }
}

事件触发

  • DoAutoAction​中判断 action​是否为 CHAT
  • e02​配置表中读取剧情数据,调用 AddChatList​,添加到对话列表
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
private void Update() {
    this.UpdateEvents();
}

private void UpdateEvents()
{
    this.InitEvent(pair.Value);
}

private int InitEvent(Event _event)
{
    if (row.trigger == "AUTO")
    {
        this.DoAutoAction(_event, row);
    }
    else if (row.trigger == "FOLLOW")
    {
        this.DoAutoAction(_event, row);
    }
}

private void DoAutoAction(Event _event, gang_e01Table.Row ev)
{  
    char[] separator = new char[] { '|' };
    string[] strArray = ev.action.Split(separator);
    if (strArray[0] == "CHAT")
    {
        // 从e_02表中获取对话列表
        // S_01_CHAT_3,player,0,井不是很深,要不要施展轻功下去看看?
        List<gang_e02Table.Row> list = SharedData.Instance(false).e02.FindAll_chatid(strArray[1]);
        if (list.Count <= 0)
        {
            this.AddChatList(_event, (_event.obj == null) ? "???" : _event.obj.name, "【测试】哎呀?我的台本哪里去了?我现在应该说啥?", 0);
            this.m_Canvas_Event = _event;
        }
        else
        {
            foreach (gang_e02Table.Row row in list)
            {
                string str = this.FormatDynamicText(row.chating);
                // 主角说话
                if (row.side == "player")
                {
                    this.AddChatList(null, this.player.charadata.Indexs_Name["Name"].stringValue, str, 0);
                    continue;
                }

                Event eventById = this.GetEventById(row.eventid);
                if ((eventById != null) && (eventById.obj != null))
                {
                    this.AddChatList(eventById, row.side, str, 0);
                    continue;
                }

                this.AddChatList(null, row.side, str, -1);
            }
        }
        this.m_Canvas_Event = _event;
     }
}

 public void AddChatList(Event _event, string _name, string _chat, int _position = 0)
{
    if (!this.IsChating())
    {
        this.Chat(_event, _name, _chat, _position);
    }
    else
    {
        ChatInfo item = new ChatInfo {
            ev = _event,
            name = _name,
            text = _chat,
            position = _position
        };
        this.m_ChatList.Add(item);
    }
}

若当前不是在对话中,则显示对话框,并替换对话框中的文字

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
private void Chat(Event _event, string _name, string _chat, int _position = 0)
{
    GameObject gameObject = null;
    if (_event == null)
    {
        gameObject = this.player.gameObject;
        this.PlayerTakeCamera();
    }
    else
    {
        gameObject = _event.obj;
        this.EventTakeCamera(_event, false);
    }
    Transform transform = null;
    if ((_position >= 0) && (gameObject != null))
    {
        transform = gameObject.transform.Find("head_icon/talk");
        if (transform != null)
        {
            transform.gameObject.SetActive(true);
        }
    }
    this.m_Menu3.Close();
    this.m_Dialog.SetActive(true);
    this.m_Chat_Name.transform.parent.gameObject.SetActive(false);
    this.m_Chat_NameL.transform.parent.gameObject.SetActive(true);
    this.m_Chat_NameL.text = _name;
    this.PlaySoundEffect("Talk", "0", null);
    this.m_Chat_Text.text = _chat.Replace(@"\n", "\n");
    if (transform != null)
    {
        this.m_Chat_Icon = transform.gameObject;
    }
    else
    {
        this.m_Chat_Icon = null;
    }
}

当前事件结束后,设置 nextflag​,进入下一个事件,重复整个流程。

其它的事件整体流程大同小异,只是根据事件行为的不同,展示不同的效果。

总结

总体来说,游戏就是通过地图、事件配置表、代码三部分,将游戏逻辑串了起来

  • 事件配置表定义事件 ID、触发方式、显示动画、行为
  • 地图上对应坐标上配置 role,名称上包含事件 ID,等待触发
  • MapControlle​负责地图事件调度,当角色通过鼠标点击、移动等行为触发到地图上的事件实体时,界面展示对应的行为

不过代码的实现看起来还可以优化,目前所有的事件都写在同一个类中,有将近 4000 行的代码,且有大量的字符串硬编码,不利于扩展。将事件和行为抽离出来,更有利于后期维护和扩展。

0%