英语单词拼写游戏开发纪录

将游戏工程更新到了Unity2018,并开源到GitHub。游戏源码:https://github.com/sunsvip/SpellingGame

 

2014年开始学习游戏开发,至今也算有些年头了,虽然折腾过不少项目,却从未留下点痕迹,为了将来面试加分,为了以后查阅方便,我决定逼着自己写下这篇博文记录项目开发过程与心得。

为什么会想到开发单词拼写游戏?

英语老师每周会发四六级真题词汇文件让我们拼写,词量很大,记起来枯燥无味,不管你有没有耐心,反正我是没耐心的。当我看到同学在纸上一遍一遍写满单词却然并卵的时候,我知道,是时候站出来拯救世界了 

于是我利用听了等于没听的编译原理课的时间向拯救世界迈出了伟大的第一步(策划):

1.词库管理:下载、更新、删除、设置任务词库
2.词库解析:从词库文件将单词和对应汉语意思抓取出来并保存
3.游戏:加载已解析的任务词库进行游戏

1.词库管理

打开词库管理时会从网络端读取config.xml, xml中存储了所有词库的下载地址、config.xml的版本号以及app版本号,以便更新数据

1.下载词库:读取xml中对应词库的下载地址,并下载词库txt文件到本地
2.更新词库:
3.删除词库:删除本地下载的词库文件
4.设置为默认词库:游戏时会读取默认词库中的单词
什么是词库文件:
将老师发的单词列表doc文件中的文本复制出来保存成.txt格式

[2017.3.25新增用户自定义词库功能]

5.用户自定义词库:用户根据一定格式自定义单词库txt文件,然后将txt文件放入指定文件夹下,词库管理器会自动加载自定义词库

用户自定义词库涉及到txt词库文件的编码方式问题,例如,window下用记事本创建的txt文件默认是ANSI编码方式,由于Unity www默认以utf-8方式加载文件,这样用www加载会出现乱码的现象,导致不能正确解析词库。所以在解析词库前需要将用户自定义词库转码为utf-8

转换为UTF-8代码:

void ConvertToUtf8(string file)
    {
        string filePath = Application.persistentDataPath + "/" + file;
        StreamReader sr = new StreamReader(filePath, Encoding.GetEncoding("GB18030"));
        string text = sr.ReadToEnd();
        sr.Close();
        StreamWriter sw = new StreamWriter(filePath, false, Encoding.UTF8);
        sw.WriteLine(text);
        sw.Flush();
        sw.Close();
    }

自定义词库文本格式要求:由于词库解析模块的正则匹配写得还算灵活,所以对自定义词库并无单一确定的格式,只要遵循单词+汉字或者汉字+单词,并且确保单词和对应汉字在同一行即可。

自定义词库示例:

词库管理界面截图:

词库管理相对简单,只是一个Scroll View,然后在Content中添加自定义的Item作为子对象,计算每个Item应该排列的位置,以及如果Item超出Content范围需要加大Content的高度,经过一番啪啪啪,代码搞定了。然后发现Component->Layout下有很多自带布局组件,可以不需一行代码就能搞定这件事,当时我的内心是崩溃的,WTF
VerticalLayoutGroup将子物体垂直排列,ContentSIzeFilter自适应大小,两个组件搞定。

2.词库解析

用正则表达式(Regex)抓取txt文件中的单词、以及对应汉语意思
老师发的单词列表有以下几种格式:

大学英语4级考试历年真题精解词汇
模拟考试一
Part II Listening Comprehension
Section A   
1.	minimum  ['mɪnəməm]  n. 最低限度;最小量
5.	lay off  暂时解雇,裁员;
9.	addressing  v.处理;  演讲;  称呼(address的现在分词);  致函
17.	get by通过,经过,混过,勉强过活
41.	a date with sb. 和某人约会
85.	Silicon Valley   phr.硅谷
59.	child-sized adj. 小孩般大小的
Part IV Translation
144.	 半决赛:semi-final
146.	 著名景点和历史名胜: scenic spots and places of interest

既然是正则匹配当然要先找规则啦。。。
①一种是英文在前意思在后,一种是意思在前英文在后
规则:若干英文字母+若干任意字符+若干汉字或  若干汉字+若干任意字符+若干英文字母

正则表达式:

[a-zA-Z]+.*[\u4e00-\u9fa5]+|[\u4e00-\u9fa5]+.*[a-zA-Z]+

代码:

string pattern = @"[a-zA-Z]+.*[\u4e00-\u9fa5]+|[\u4e00-\u9fa5]+.*[a-zA-Z]+";
                var mat = Regex.Match(text, pattern);

②去掉无关字符,例如音标或中文标点符号

规则:[英语音标]和中文标点符号
正则表达式和代码:

③归一化空白字符(将多空格统一替换为一个空格)

正则表达式:

[ \n\s*\r]+

代码:

str = Regex.Replace(str, @"[ \n\s*\r]+", " ");

④排除语义项。例如:

a date with sb. 和某人约会
child-sized adj.孩子般大的
其中sb.(哦,怎么感觉怪怪的)和adj.有着不同的语义,很明显sb.应该算到单词部分。而adj.是词性,应该算到汉语意思部分。
规则:首先要找到所有英文字母+.的部分,然后判断如果是sb.或sth.则划分到单词部分,如果否则归为汉语意思部分。
正则表达式:

[a-zA-Z]+\.

用正则表达式完全是为了偷懒,图个方便。复杂的正则匹配效率并不高,所以解析完成后将提取出的单词和对应中文写入xml文件,以便下次直接加载,这样每个词库只解析一次。

【Warning】:

1.window下用WWW加载本地下的文件时文件路径中有"/"就会读取失败,把“/”换成“\”才能正常读取

string filePath = Application.persistentDataPath + "\" + filename;
        var www = new WWW("file://" + Regex.Replace(filePath, "/", @"\"));

解析词库生成的xml文件格式如下:

<?xml version="1.0" encoding="utf-8"?>
<VocList>
  <Voc>
    <en>hazard</en>
    <zh>n.危险,冒险,危害 vt.冒险,赌运气</zh>
  </Voc>
  <Voc>
    <en>identification</en>
    <zh>n.确认,视为同一,证明同一,身份的证明</zh>
  </Voc>
  <Voc>
    <en>be associated with</en>
    <zh>与……有关 与……有关系</zh>
  </Voc>
</VocList>

2.从网络端获取xml文件内容,直接用XmlDocument.LoadXml(string xmlContent)来解析xml内容时会报错。提示第一行没有节点,如上所示,xml文件的第一行存的是xml版本和编码方式等信息,我的解决方式是先删掉第一行再LoadXml解析。

【2017.3.19日更新】

3.游戏模块

不知不觉差不多一周过去了,然而项目并无太大进展。没有精力和时间自己做游戏素材(时间都用在打王者荣耀了),最终在应用商店下载到两款合适的游戏,并无耻地窃取了别人的游戏素材,当然了,不会商用,只是玩玩。

随着项目的推进,遇到的难题也越来越多,我是打算把游戏发布为Windows、Android、ios平台,windows平台有键盘,单词输入很方便,但是手机平台如何输入单词?如果直接用输入框(Input Field)输入时会弹出手机上的输入法,完全遮住游戏画面,单词游戏的游戏性本就有所限制,这样就更不能忍受了,为了用户体验,只好自己做个英文输入法了

游戏界面截图:

1.塔防模式游戏描述:

如上图,左边是城墙,上面有弓箭。可爱的爆笑虫子会从右边出现向城墙逼近,并发起攻击,每个虫子头上都有单词,玩家正确输入虫子头上的单词,就会射箭将对应的虫子杀死。

【2017.3.22日更新】

2.关卡设计:

为了增加游戏的趣味性,把游戏设计成了闯关模式,关卡个数由当前任务词库的单词量而定,每个大关卡学习n个单词,且每个大关卡由普通关卡、进阶关卡和Boss关卡组成。

1.普通关卡(初步记忆):敌人头上会一直显示单词和意思

2.进阶关卡(加深记忆):敌人头上的单词会间隔数秒闪现,也可以使用技能显示单词

3.Boss关卡(实战检测):敌人头上不显示单词,只能通过技能使单词闪现

①关卡选择页面的初始化:

首先从用户设置的任务词库中读取总单词的个数,根据总单词数除以每个大关卡需要学习的单词数,计算出此任务词库需要创建多少个关卡。再用总单词数模除每个大关卡需要学习的单词书,得到剩余单词。如果剩余单词大于一定数量则新建一个关卡,如果剩余单词过少则直接把单词附加到最后一个大关卡中。

关卡按钮:显示对应关卡,关卡是否解锁,关卡的通关评星

这些关卡的通关状态等属性,需要保存到后台。所以每个词库都要有一个关卡配置文件来储存关卡信息。

关卡配置文件如下:

<LevelList>						关卡列表
  <Level>
    <Num>1</Num>				第几关
    <VocIndex>0-10</VocIndex>	该关卡要学的单词索引
    <ChildLevelList>			子关卡1
      <ChildLevel>
        <Num>1</Num>			第几个子关卡
        <Unlock>1</Unlock>		子关卡是否解锁
        <Pass>0</Pass>			子关卡是否通关
        <Star>0</Star>			通关评星
        <LevelType>1</LevelType>子关卡类型 1.普通关卡 2.进阶关卡 3.Boss关卡
      </ChildLevel>
      							...每个大关卡由多个子关卡组成...
  </Level>
</LevelList>

②场景间的数据传递:

当玩家选择不同的关卡,进入游戏场景时会有不同的游戏难度、学习不同的单词等属性,因此关卡选择场景和游戏场景之间需要一个桥梁进行数据的传递,可以通过DontDestoryOnLoad()来实现切换场景时不销毁当前场景的对象,类似Cocos2d-x retain()函数。也可以用PlayerPrefs类实现,当玩家选择关卡时把对应的关卡的数据用PlayerPrefs存入本地,由游戏场景根据PlayerPrefs读取数据进行游戏。

关卡选择页面演示:

【Warning】:

唉,开发过程中各种奇怪的小问题层出不穷,对各种逻辑、算法、细节及灵活度的深究也十分的烧脑,我不是个较真儿的人,但有时有点完美主义,写策划的时候推演了无数次的逻辑,写代码时还要一遍一遍的改进。看,你把别人泡妞的功夫都耗在了事业上,活该你单身。在这个稍微立志点就会被别人当成搞传销的环境里,作为埋头做事不问红尘的过来人,我要奉劝一句:“鸡汤”看多毁一生,人生得意须尽欢。

好吧,360度转入正轨,关卡布局用的是Unity自带的Layout组件,不知道他们的组件脚本是怎么实现的,当从关卡场景切换到主菜单场景,然后再切换到关卡场景时,Scroll View中ViewPort的锚点就会莫名其妙的改变,导致关卡选择按钮全部消失,找原因未果,只好在关卡场景中的某个脚本的Start方法中对ViewPort的锚点进行矫正。

【2017.3.24日更新】

老师说29号学校有招聘会,为了能在简历上多加一个项目经验,不得不加快进度了,玩游戏、追剧浪费了很多时间,或许人只有在逆境中才会争分夺秒,安逸是消磨意志的温床。然而我们听过无数的道理,却仍旧过不好这一生。为什么呢?WillPower

3.游戏场景:

①Enemy的实现:

每个Enemy都具有以下属性:

1.Enemy对应的单词,一个Enemy可以对应n个单词,相当于要射n次才能消灭这个敌人(n可以表示为敌人的血量),每受击一次显示下一个单词

2.移动速度(不同类型的Enemy有不同的移动速度)

3.攻击速度(不同类型的Enemy有不同的攻击速度)

4.单词框(显示单词和意思),单词框底部有Enemy的红色血条

5.技能效果,当玩家拖动技能到指定Enemy,要做出对应的技能响应。例如玩家使用了冰冻技能则Enemy会暂时停止移动,玩家使用减速技能,Enemy的移动速度会降低。

当Enemy被创建后,会向塔移动,如果到达塔下则停止移动并每隔数秒攻击一下

Enemy截图:

【2017.3.26日更新】

②给每个Enemy填充单词

读取当前关卡对应要学的单词索引PlayerPrefs.GetString("LevelVocIndex")

然后从词库中取出对应的单词作为本关卡的单词列表,每创建一个Enemy就从单词列表中取出单词赋给Enemy,每个Enemy可对应n个单词,表示此Enemy血量为n,需要射中n次才能消灭此Enemy

③弓箭的控制

当玩家点击提交按钮后,拿玩家输入的单词与当前所有敌人对应的单词进行比较,如果输入单词正确,弓箭瞄准对应的Enemy,然后射箭

如果是3D用transform.LookAt(target),就能实现弓箭瞄准某物体的效果,但是对于2D确不适用,所以只好自己计算

弓箭瞄准的计算:

angle得到的是弧度,所以要转换为角度

当弓箭朝向目标以后,创建箭头,并控制箭头飞向目标。

其实箭和敌人应该写个对象池进行管理,以减小不停创建和销毁带来的性能损耗,但考虑到单词游戏射箭和敌人出现的数量很少,所以暂时没有用对象池。

逻辑代码:

public void Shoot(Transform target)
    {
        var dir = target.position - transform.position;
        var rotate = Mathf.Atan(dir.y / dir.x) * Mathf.Rad2Deg;
        transform.rotation = Quaternion.Euler(0, 0, rotate);
        //旋转到指定角度后射箭
        var arow = Instantiate(arowPrefabs[0], transform.position, transform.rotation) as GameObject;
        StartCoroutine(ArowMove(arow.transform, target));
    }
    IEnumerator ArowMove(Transform arow, Transform target)
    {
        while (!(Vector3.Distance(arow.position, target.position) <= 0.1f))
        {
            arow.transform.position = Vector3.MoveTowards(arow.position, target.position, Time.deltaTime * arowSpeed);
            yield return new WaitForEndOfFrame();
        }
        Destroy(arow.gameObject);
        target.GetComponent<Enemy>().Damage();
    }

④由于项目最终会发布为windows、android、ios,如果是windows平台就不需要虚拟键盘,可以直接用键盘输入,如果是移动平台则显示虚拟键盘

因为是单词拼写,所以不需要复杂的字符,定义一个字符串存放所有能输入的字符:

private bool InputAvailable(char ch)
        {
            char inputChar = ch;
            if (inputChar == '.' || inputChar == '-' || (inputChar >= 'a' && inputChar <= 'z')|| (inputChar >= 'A' && inputChar <= 'Z'))
                return true;
            return false;
        }
string str = Input.inputString;
        if (str.Length > 0 && InputAvailable(str[0]))
        {
            更新输入框中的内容
        }


【Warning】:

本想让弓箭经过平滑旋转瞄准敌人然后再射箭,这样看起来比较舒服,然而确遇到了坑。如果弓箭旋转角度等于目标角度则xxx, 这里需要获取弓箭的角度与目标角度进行比较,但是奇怪的事情发生了,unity会自动优化角度,例如360度、-360度、0度,表示的是相同的角度,但是数值大小却不同,这就与前面的逻辑矛盾了。

【啊偶,已经是2017.3.27  0:36分了,该睡觉了】

最后来张效果图:

【2017.3.27日更新】

动态添加AnimationEvent动画回调函数:

游戏中的Enemy用的是Animator动画系统控制的。当Enemy每播放一次攻击动画就需要调用一次塔受击减血的函数,当Enemy播放完死亡动画需要回调函数销毁该Enemy。每创建出一个Enemy就对其设置相应的动画回调事件,由于相同类型的Enemy是共用Animation Clip的,所以就会导致同一个Animation Clip添加了多个相同的回调函数,这就厉害了,例如,Enemy播放攻击动画时会调用塔受攻击减血的函数,由于重复添加了回调函数,导致每播放一次攻击动画,塔执行多次减血函数,相当于秒杀啊。所以在添加动画回调函数之前应先判断是否已经存在回调函数:

void Awake()
    {
        animCtrl = GetComponent<Animator>();
        var animClips = animCtrl.runtimeAnimatorController.animationClips;
        foreach (var clip in animClips)
        {
            //如果动画片段上已经有响应事件了则直接返回 避免重复添加造成多次响应
            if (clip.events.Length > 0)
                break;
            switch (clip.name)
            {
                case "damage":
                    {
                        AnimationEvent animEvent = new AnimationEvent();
                        animEvent.time = clip.length;
                        animEvent.functionName = "DamageAnimEndCallBack";
                        clip.AddEvent(animEvent);
                    }
                    break;
                case "attack":
                    {
                        AnimationEvent animEvent = new AnimationEvent();
                        animEvent.time = clip.length;
                        animEvent.functionName = "AttackAnimEndCallBack";
                        clip.AddEvent(animEvent);
                    }
                    break;
                case "death":
                    {
                        AnimationEvent animEvent = new AnimationEvent();
                        animEvent.time = clip.length;
                        animEvent.functionName = "DeathAnimEndCallBack";
                        clip.AddEvent(animEvent);
                    }
                    break;
            }
        }
        animCtrl.SetBool(Conf.moveAnim, true);
    }


分辨率的适配:

由于不同手机分辨率会有差异,而游戏背景图的分辨率是不变的,这样就会导致游戏画面穿帮,如图:

因此需要通过代码将背景Sprite平铺,充满屏幕分辨率,不过这样会导致图片变形,因此最好将背景Sprite设成9宫格,Draw Mode设置为Sliced。

注:我当前使用的Unity版本为5.6.0f1,Unity是从这个版本才开始才支持9-Slice Sprite

多分辨率适配实现:

//屏幕适配
    void SpriteSizeFitter()
    {
        //背景Sprite
        var bgSprite = background.GetComponent<SpriteRenderer>();
        var wallSprite = wall.GetComponent<SpriteRenderer>();
        //获取屏幕的世界坐标系大小 不是屏幕分辨率
        var halfsize = Camera.main.ViewportToWorldPoint(Vector2.one);//注意Viewport坐标系左下角是(-1,-1),右上角是(1,1)
        bgSprite.size = new Vector2(halfsize.x * 2, halfsize.y * 2);//将背景Sprite大小设置为与屏幕相同大小
        wall.transform.position = new Vector3(-halfsize.x + wallSprite.bounds.size.x / 2, 0, wall.transform.position.z);
        wallSprite.size = new Vector2(wallSprite.size.x, halfsize.y * 2);
        bow.gameObject.transform.position = wall.transform.position + new Vector3(-0.2637f, 0, 0);
    }

与上图同分辨率,适配后效果如图:

【2017.3.28日更新】

目前单词游戏的塔防模式基本已经完成,剩下很多小细节需要修改。但是,积攒了很多天的作业都已经到了Deadline,为什么大学要布置这么多作业?

4.Enemy动画系统:

①普通Enemy的动画系统以及触发条件:

【2017.4.4日更新】

最近更新了一些细节,实在没有耐心了,完善琐碎的细节既耗时又没有技术含量,很枯燥。所以不再继续了,目前塔防模式收工,虽然这个项目很无趣,无新意,不过通过这个项目重温了很多技术,有很大收获。待日后发现有创意的新颖玩法再开发新的模式。接下来开始专注我喜欢的图形学技术,很久没研究Unity Shader了。

简单录了段游戏视频,懒得剪辑。

游戏录屏:单词拼写游戏_腾讯视频

游戏下载地址:单词拼写游戏_免费高速下载|百度网盘-分享无限制

游戏截图:

  • 7
    点赞
  • 40
    收藏
    觉得还不错? 一键收藏
  • 15
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 15
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值