H5开发带有地图编辑器的游戏

Laya的H5开发

Posted by BYDrinker on October 31, 2020

# H5开发带有地图编辑器的游戏

  • 首先我们需要一个编辑器相关的东西得借助什么。这块有很多种选择,比如xml,csv、json、甚至纯文本都是可以的

但是基于小巧和种种原因,我最终选择了json,并且决定地图编辑器用U3d实现,而游戏则用H5实现

首先思考何为地图编辑器,这个得从游戏开发中的脚本开始说起了,其实脚本语言和外置的json、xml高度相似,如果你读过游戏的编译原理这本书的话…

书籍地址:https://book.douban.com/subject/1927405/

1.地图编辑器到底需要什么

首先在游戏里,必然存在地形、障碍物、敌人、特效多种类型的元素,当然如果要设计一些比较高端的元素可能需要在json中外部引用进行扩展脚本,这里就不在这一次探讨的范围,这次我们主要还是区分地形,障碍物、敌人、特效这几种情况进行设计。

image

1.如果采取json格式如何设计数据类型

不同的敌人、障碍物、地形都得设置不同的参数,json包括的作用就很好显示出来了,也就是葫芦娃套娃,一层套一层,单位放在单位类里面,而这个单位的属性又放在单位这个类里面,单位的属性又有具体的子属性。

为了方便大家的理解,以我的游戏“一冲无前”为例,我这里直接放出一个实例给大家看看

{
    "enemy": [
        {
            "id": 207,
            "type": 2,
            "name": "瘦子",
            "posX": 55.0,
            "posY": 0.0,
            "C": 30.0,
            "Size": 0.10000000149011612,
            "shakeTime": 300.0,
            "speedipa": 300.0,
            "idleAni": "slim_idle",
            "beHitAni": "slim_die",
            "audioSrc": "Q",
            "isbeHit": true
        },
        {
            "id": 901,
            "type": 2,
            "name": "女神",
            "posX": 80.0,
            "posY": 0.0,
            "C": 10.0,
            "Size": -0.15000000596046449,
            "shakeTime": 100.0,
            "speedipa": 100.0,
            "idleAni": "Lady_idle",
            "beHitAni": "Lady_die",
            "audioSrc": "Q",
            "isbeHit": false
        },
        {
            "id": 207,
            "type": 2,
            "name": "瘦子2",
            "posX": 25.0,
            "posY": 0.0,
            "C": 30.0,
            "Size": 0.10000000149011612,
            "shakeTime": 300.0,
            "speedipa": 300.0,
            "idleAni": "slim_idle",
            "beHitAni": "slim_die",
            "audioSrc": "Q",
            "isbeHit": true
        }
    ],
    "Length": 100
}

引入眼帘就是各种参数了,这里参数并没有进行太过复杂的套娃,毕竟我目前开发这款游戏属于偏休闲横板游戏,自然也没有设计过于复杂的参数和套娃模式了。

这里还是给大家介绍下这些参数的作用。

字段名 数值类型 作用
id number 207 用于区分具体对象
type number 2 进行功能函数调用
name String 瘦子(敌人) 用于给对象起名字
posX number(float) 55.0 判断对象x位置
posY number(float) 0.0-为了避免参数太多,这里应该和x放在一起,string里逗号进行split分开比较好但是这里为了比较便于理解就分开了 判断对象y位置
C number(float) 30.0 参数C,通过公式来计算,来为不同单位添加不同的打击感
Size number(float) 0.10000000149011612 参数SIze,通过公式来计算,来为不同单位添加不同的打击感
shakeTime number(float) 300.0 单位被击杀后晃动摄像机时间
idleAni String slim_idle 单位默认的动画文件
beHitAni string slim_die 单位被打击事播放的动画文件
audioSrc string Q 单位死亡播放的声音文件
isbeHit bool true 是否需要被击杀进行计分
func1 obj everyThing 预留字段,保留设计
func2 obj everyThing (可能用作后期扩展脚本)

这里看上去有些参数过多的感觉,但这些参数都只属于地图编辑器内部的字段,如果每一项每一次都要使用编辑器的人来填写,那么肯定会发疯,所以我们可以设置一个保存一堆默认组件的json,使用默认组件会自动为所有其他参数赋默认值。这样使用编辑器的人就舒服了。可以直接把对象调出来
{
    "enemy": [
	
		{
            "id": 901,
            "type": 2,
            "name": "女神",
            "posX": 5.0,
            "posY": 0.0,
            "C": 10.0,
            "Size": -0.15,
            "shakeTime": 100.0,
            "speedipa": 100.0,
			"idleAni": "Lady_idle",
            "beHitAni": "Lady_die",
            "audioSrc": "Q",
            "isbeHit": false
        },
        {
            "id": 101,
            "type": 2,
            "name": "草坪",
            "posX": 5.0,
            "posY": 0.0,
            "C": 10.0,
            "Size": 0.02,
            "shakeTime": 100.0,
            "speedipa": 100.0,
			"idleAni": "grass_idle.png",
            "beHitAni": "null",
            "audioSrc": "Q",
            "isbeHit": false
        },
        {
            "id": 102,
            "type": 1,
            "name": "酒瓶",
            "posX": 10.0,
            "posY": 0.0,
            "C": 20.0,
            "Size": 0.05,
            "shakeTime": 200.0,
            "speedipa": 200.0,
            "idleAni": "bottle_idle.png",
			"beHitAni": "null",
            "audioSrc": "duang",
            "isbeHit": false
        },
        {
            "id": 103,
            "type": 3,
            "name": "地刺",
            "posX": 15.0,
            "posY": 0.0,
            "C": 20.0,
            "Size": -0.25,
            "shakeTime": 200.0,
            "speedipa": 300.0,
			"idleAni": "spikes_idle.png",
            "beHitAni": "null",
            "audioSrc": "Q",
            "isbeHit": false
        },
        {
            "id": 201,
            "type": 2,
            "name": "碎石",
            "posX": 20.0,
            "posY": 0.0,
            "C": 10.0,
            "Size": 0.05,
            "shakeTime": 200.0,
            "speedipa": 200.0,            
			"idleAni": "debris.png",
            "beHitAni": "null",
            "audioSrc": "pu",
            "isbeHit": false
        },
        {
            "id": 202,
            "type": 2,
            "name": "水坑",
            "posX": 25.0,
            "posY": 0.0,
            "C": 20.0,
            "Size": 0.01,
            "shakeTime": 200.0,
            "speedipa": 200.0,            
			"idleAni": "pool_idle.png",
            "beHitAni": "null",
            "audioSrc": "pu",
            "isbeHit": false
        },
        {
            "id": 203,
            "type": 2,
            "name": "广告牌",
            "posX": 30.0,
            "posY": 0.0,
            "C": 20.0,
            "Size": 0.01,
            "shakeTime": 200.0,
            "speedipa": 200.0,            
			"idleAni": "NoPeeSign_idle",
            "beHitAni": "NoPeeSign_die",
            "audioSrc": "Q",
            "isbeHit": false
        },
        {
            "id": 205,
            "type": 2,
            "name": "汽车",
            "posX": 41.0,
            "posY": 0.0,
            "C": 50.0,
            "Size": 0.1,
            "shakeTime": 400.0,
            "speedipa": 400.0,            
			"idleAni": "car_idle",
            "beHitAni": "car_die",
            "audioSrc": "Q",
            "isbeHit": false
        },
        {
            "id": 206,
            "type": 2,
            "name": "油桶",
            "posX": 46.0,
            "posY": 0.0,
            "C": 40.0,
            "Size": 0.01,
            "shakeTime": 400.0,
            "speedipa": 400.0,            
			"idleAni": "oildrum_idle",
            "beHitAni": "oildrum_die",
            "audioSrc": "oil",
            "isbeHit": false
        },
        {
            "id": 207,
            "type": 2,
            "name": "瘦子",
            "posX": 51.0,
            "posY": 0.0,
            "C": 30.0,
            "Size": 0.1,
            "shakeTime": 300.0,
            "speedipa": 300.0,            
			"idleAni": "slim_idle",
            "beHitAni": "slim_die",
            "audioSrc": "Q",
            "isbeHit": true
        },
        {
            "id": 208,
            "type": 2,
            "name": "胖子",
            "posX": 56.0,
            "posY": 0.0,
            "C": 50.0,
            "Size": 0.1,
            "shakeTime": 400.0,
            "speedipa": 400.0,            
			"idleAni": "fat_idle",
            "beHitAni": "fat_die",
            "audioSrc": "Q",
            "isbeHit": true
        },
        {
            "id": 105,
            "type": 1,
            "name": "易拉罐",
            "posX": 66.0,
            "posY": 0.0,
            "C": 10.0,
            "Size": 0.05,
            "shakeTime": 100.0,
            "speedipa": 100.0,            
			"idleAni": "can_idle.png",
            "beHitAni": "null",
            "audioSrc": "duang",
            "isbeHit": false
        },
        {
            "id": 106,
            "type": 1,
            "name": "篮球",
            "posX": 71.0,
            "posY": 0.0,
            "C": 30.0,
            "Size": 1.0,
            "shakeTime": 100.0,
            "speedipa": 200.0,            
			"idleAni": "ball_idle.png",
            "beHitAni": "null",
            "audioSrc": "box",
            "isbeHit": false
        }
    ],
    "Length": 100
}

2.编辑器的代码实现

下面这个章节,着重描述如何设计出地图编辑器的核心功能

  • 如何读写文件(读取/载入写好的界面)
  • 设计地图编辑器的ui(懒狗就用OnGui,效率低,大家可以换成Ugui)
  • 设计交互逻辑,增加限定条件

如何读写文件(读取/载入写好的界面)
private void OnSave()
{
    OpenFileName openFileName = new OpenFileName();

    openFileName.structSize = Marshal.SizeOf(openFileName);

    openFileName.filter = "json文件(*.json)\0*.json";

    openFileName.file = new string(new char[256]);

    openFileName.maxFile = openFileName.file.Length;

    openFileName.fileTitle = new string(new char[64]);

    openFileName.maxFileTitle = openFileName.fileTitle.Length;

    openFileName.initialDir = Application.streamingAssetsPath.Replace('/', '\\');//默认路径

    openFileName.title = "选择地图生成的路径";

    openFileName.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000008;

    if (LocalDialog.GetSaveFileName(openFileName))

    {

        Debug.Log(openFileName.file);

        Debug.Log(openFileName.fileTitle);

        EnemyData inputDate = new EnemyData
        {
            enemy = new Enemy[enemyList.Count]
        };
        for (int i=0;i<enemyList.Count;i++)
        {

            inputDate.enemy[i] = enemyList[i].GetComponent<EnemyObj>().e;

        }
        inputDate.Length = boardLenth;

        string json = JsonUtility.ToJson(inputDate, true);
        File.WriteAllText(openFileName.file+".json", json, Encoding.UTF8);

    }
}

​ 通过LocalDialog.GetSaveFileName,获取路径,然后通过jsonUtilityToJson将数组给序列化,具体enemy里面保存的就是我们json中各个字段

using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;

[Serializable]
public class Enemy
{
    public int id;
    public int type;
    public string name;
    public float posX;
    public float posY;
    public double C;
    public float Size;
    public float shakeTime;
    public float speedipa;
    public string idleAni;
    public string beHitAni;
    public string audioSrc;
    public bool isbeHit;

}
[Serializable]
public class EnemyData
{
    public Enemy[] enemy;
    public int Length;
}

然后我们看看读取的代码,其实也大同小异

public void Load()
{
    if (enemyList.Count > 0)
    {
        isOpenWindow = true;
        titleContents = "当前场景有还在编辑的对象,是否放弃编辑,打开场景";
        return;
    }
    OnOpen();

}

private void OnOpen()
    {
        OpenFileName openFileName = new OpenFileName();

        openFileName.structSize = Marshal.SizeOf(openFileName);

        openFileName.filter = "json文件(*.json)\0*.json";

        openFileName.file = new string(new char[256]);

        openFileName.maxFile = openFileName.file.Length;

        openFileName.fileTitle = new string(new char[64]);

        openFileName.maxFileTitle = openFileName.fileTitle.Length;

        openFileName.initialDir = Application.streamingAssetsPath.Replace('/', '\\');//默认路径

        openFileName.title = "选择地图的路径";

        openFileName.flags = 0x00080000 | 0x00001000 | 0x00000800 | 0x00000008;
        EnemyData enemyDate=null;
        if (LocalDialog.GetOpenFileName(openFileName))

        {

            Debug.Log(openFileName.file);

            Debug.Log(openFileName.fileTitle);
            if (!File.Exists(openFileName.file))
                return ;
            StreamReader sr = new StreamReader(openFileName.file);
            if (sr == null)
                return ;
            string json = sr.ReadToEnd(); 
            if (json.Length > 0) 
                enemyDate = JsonUtility.FromJson<EnemyData>(json);
            if (enemyDate != null) {

                //地图长度生成
                groud.SetActive(true);
                boardLenth = enemyDate.Length;
                Text groundLength = groud.GetComponentInChildren<Text>();
                groundLength.text = "地图长度" + boardLenth + "米";

                foreach (Enemy t in enemyDate.enemy) {

                    //530是终点
                    float len = (t.posX / boardLenth) * 530;
                    GameObject game = GameObject.Instantiate(beInstance, canvas.gameObject.transform);
                    game.name = t.name;
                    game.GetComponent<RectTransform>().anchoredPosition = new Vector2(-393 + len, -25 + t.posY);
                    game.GetComponentInChildren<Text>().text = t.name;
                    game.GetComponent<EnemyObj>().e = t;
                    Button btn = game.GetComponent<Button>();
                    enemyList.Add(game);
                    btn.onClick.AddListener(() =>
                    {
                        if (btn != null)
                        {

                            deleteGameObj = game;
                            //    enemyList.Remove(game);
                            Debug.Log(deleteGameObj.name);
                            titleContents = "选是删除对象,选否取消操作";
                            isDeleteWindow = true;
                        }
                    });
                    Debug.Log(t.posX + "y:" + t.posY);

                }
            }
        
        }
    }

前半部分是读取的代码,读取到json值赋予给地图编辑器的各个编辑窗口

最后再来描述一种我之前讲的内容,预物体,大概的思路也是读取json,通过一张完整的json表(具体可以看第一节),然后通过id获取json表的值对象

public void Find() {
    if (id.text.Length == 0)
    {
        titleContents = "查找id必须Id有值";
        isShowWindow = true;
        return;
    }
    String openFileName = Application.streamingAssetsPath + "/jsonData.json";
    int idValue = int.Parse(id.text);
    EnemyData enemyData = new EnemyData();

    if (!File.Exists(openFileName))
        return;
    StreamReader sr = new StreamReader(openFileName);
    if (sr == null)
        return;
    string json = sr.ReadToEnd();
    if (json.Length > 0) {
        enemyData = JsonUtility.FromJson<EnemyData>(json);
        foreach (Enemy t in enemyData.enemy) {
            if (idValue == t.id) {
                id.text = t.id+"";
                nameInput.text=t.name;
                type.text=t.type+"";
                posX.text=t.posX+"";
                posY.text=t.posY+"";
                C.text=t.C+"";
                Size.text=t.Size+"";
                shakeTime.text=t.shakeTime+"";
                speedipa.text=t.speedipa+"";
                beHitAni.text=t.beHitAni+"";
                audioSrc.text=t.audioSrc+"";
                toggle.isOn=t.isbeHit;
                idleAni.text = t.idleAni;
                return;    
            }
        }
    }
设计地图编辑器的ui(懒狗就用OnGui,效率低,大家可以换成Ugui)
 void OnGUI(){
     Rect windowRect;
     Rect deleteRect;
     Rect openRect;
     Rect listWindowRect;
     bool isShowWindow1 = isShowWindow;
     if (isShowWindow1 == true)
         windowRect = GUI.Window(0,new Rect(150,100, 320, 250), DoMyWindow, "提示");
     if (isDeleteWindow == true)
         deleteRect = GUI.Window(1, new Rect(150, 100, 320, 250), DeleteWindow, "是否删除这个对象");
     if(isOpenWindow==true)
         openRect = GUI.Window(2, new Rect(150, 100, 320, 250), DisCardWindow, "是否抛弃当前场景编辑的对象");
     if (isListWindows == true) {
         listWindowRect = GUI.Window(3, new Rect(150, 0, 400, 600), ListWindow, "当前所有可获取的对象");
        }
}

private void ListWindow(int id) {
    GUIStyle mystyle = new GUIStyle();
    mystyle.fontSize = 25;
    mystyle.fontStyle = FontStyle.Bold;
    mystyle.normal.textColor = new Color(163f / 256f, 163f / 256f, 163f / 256f, 256f / 256f);
   
    GUI.Label(new Rect(15, 50, 320, 250), showContent, mystyle);
    if (GUI.Button(new Rect(10, 500, 100, 50), "了解"))
    {
        isListWindows = false;
    }
}

private void DoMyWindow(int id)
{
    GUI.Label(new Rect(10, 50, 320, 250), titleContents);
    if (GUI.Button(new Rect(10, 200, 100, 50), "了解"))
    {
        isShowWindow = false;
    }
}
...

这里没什么好说的,一个ugui的简单实现,其实是懒得画ui。写了跟多无意义的代码,酌情使用哈

设计交互逻辑,增加限定条件
public void GenerateGroud() {
        if (length.text.Length > 0) {
            boardLenth = int.Parse(length.text);
            //  length.contentType =
            if (boardLenth > 0)
            {
                groud.SetActive(true);
                //groundLength.gameObject.SetActive(true);
                Text groundLength = groud.GetComponentInChildren<Text>();
                groundLength.text = "地图长度" + boardLenth + "米";       }
        else
        {
            titleContents = "地形长度必须是一个合法的值";
            isShowWindow = true;
            return;
        }
    }
}

public void AddObj() {
    if (boardLenth ==0) {
        titleContents = "必须先创建一个地形";
        isShowWindow = true;
        return;
    }
    if (posX.text.Length > 0)
    {
        float posx = float.Parse(posX.text);
        if (posx < 0 || posx > boardLenth)
        {
            titleContents = "PosX不符合规范(大于0且小于地形的长度)";
            isShowWindow = true;
            return;
        }
        foreach (GameObject t in enemyList) {
            EnemyObj enemy = t.GetComponent<EnemyObj>();
            if (Math.Abs(posx - enemy.e.posX) < boardLenth / 30.0f) {
                titleContents = "每个对象至少相隔地形长度的1/30";
                isShowWindow = true;
                return;
            }
            Debug.Log("实际相隔长度" + (posx - enemy.e.posX));
            Debug.Log("至少相隔长度"+(boardLenth / 30.0));

        }
    }
    if (nameInput.text.Length>0) {
        foreach (GameObject t in enemyList)
        {
            EnemyObj enemy = t.GetComponent<EnemyObj>();

            if (nameInput.text.Equals(enemy.name)){
                nameInput.text = nameInput.text + "1";
                titleContents = "对象名字不能相同";
                isShowWindow = true;
                return;
            }

        }
    }
    if (posY.text.Length > 0)
    {
        float posy = float.Parse(posY.text);
        if (posy > 17 || posy < -31)
        {
            titleContents = "PosY不符合规范(大于-31且小于17)";
            isShowWindow = true;
            return;
        }
    } 这里大概就是创建地形时,以及使用组件时,为游戏里可能报错的条件加入一个限制,限制地图编辑器的无脑使用,也为后期进行修改和程序提供了便利

最后成品:

image

游戏该怎么做

这个,这篇文章,主要还是讲地图编辑器相关的,但我会放出游戏地址和地图编辑器Github的相关代码。

游戏地址:能玩的

Github:能康的

虽然我觉得大家看看源码就应该能看懂这个项目了,不过我还是想看看大家的反馈,对游戏制作相关方面有没有兴趣,如果反馈的好,就会有下集哦