分析篇-地图设计与事件
大江湖的地图设计没有使用原生的 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
处于地图的最底层,主要放置了以下几种对象
地图背景图片
整个地图的背景图片都绘制在这一层,这个很好理解,图片必须放在最底层,否则所有的元素都会被它遮挡
鼠标指针
在 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
eventid | ID | STAGE_01_7 | STAGE_01_7 | STAGE_01_7 | STAGE_01_7 |
---|
flag | 存档记录 flag | STAGE_01_7 | STAGE_01_7_SEL | STAGE_01_7_SEL_1 | STAGE_01_7_SEL_2 |
trigger | 触发方式AUTO-自动FOOTON-经过CLICK-点击FOLLOW-跟随 | CLICK | FOLLOW | FOLLOW | FOLLOW |
display | 展示 prefab | Prefabs/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-穿过 | CHAT | S_01_CHAT_2 | SELECT | S_01_CHAT_2_SELECT_1&S_01_CHAT_2_SELECT_2 |
elseaction | 未启用 | | | | |
throughaction | 未启用 | | | | |
nextflag | 下次事件 | STAGE_01_7_SEL | | STAGE_01_7 | STAGE_01_7 |
output | end-结束事件 | | | | |
elsefollow | 未使用 | | | | |
menu | 弹出菜单 | 0 | 0 | 0 | 0 |
online | 是否启用 | 1 | 1 | 1 | 1 |
chatid | side | eventid | chating |
---|
对话 ID | 0-系统player-主角其他-名字 | 所属事件 id | 对话内容 |
S_01_CHAT_2 | player | 0 | 惬意小火让人陷入沉思……是否记录一下现有经历? |
S_01_CHAT_2_SELECT_1 | 0 | 0 | 记录 |
S_01_CHAT_2_SELECT_2 | 0 | 0 | 离开 |
触发 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 行的代码,且有大量的字符串硬编码,不利于扩展。将事件和行为抽离出来,更有利于后期维护和扩展。