2.4 第四步:添加复杂的交互功能
这一步将为游戏中的物件添加更为复杂的交互功能。最难理解的那些概念已经在上一步中讲完了,本步骤要讲的内容理解起来并不会太难,但是,为了实现这些新的交互操作,必须大幅修改代码。首先按照程序清单2.14中的粗体代码修改index.html。
程序清单2.14 修改index.html文件
在这一步里,我们改变了引用元素的方式。不再通过itemable这个class属性来引用幻灯片或玩家的道具栏了。另外,只用纯数字来充当元素的id值的话,不便于引用或追踪元素,所以我们在这些id属性值前面加上slide一词。id修改了之后,指向这些元素的链接也要随之更改。还要给每张幻灯片里添加一个class属性为event-text的div元素。第二张幻灯片中新加入了一只恐龙,幻灯片中的文字也要修改,我们要告诉玩家这里有只恐龙。
尽管本游戏也引入了impress.js文件,但是为了实现游戏中的一些功能,我们还需要对它略加修改才行。请按照程序清单2.15中的粗体代码修改此文件。
程序清单2.15 修改impress.js
这段代码对原来的impress.js做了两处修改。第一处改动是创建了game对象,这个位于全局作用域内的对象只在本章中使用。此对象有两个属性:一个是数组,用于保存浏览过的幻灯片;另一个是函数,用于将幻灯片加入数组中。这段代码可以放在文件顶部。第二处改动是:每次播放新幻灯片时,都要调用刚才定义的那个函数。为了执行这一改动,我们把程序清单2.15中的第二段粗体代码加入impress.js文件中,这段代码应该添加在第421行附近。
接下来,按程序清单2.16修改dragDrop.js文件。
程序清单2.16 修改后的dragDrop.js文件
这份文件有两处大的改动。首先是整段代码都包裹在一个“自执行函数”(self-executing function)里面。如果这段代码需要多次运行(比方说需要重新绑定事件监听器),那么可以把第一行改为game.dragdrop=(function)(){,这样就会把此函数作为属性放到全局的game对象之中,不过,在此步骤里,这段代码只需执行一次即可,所以不用这么写。第二处改动是,handleDrop函数与原来略有不同。现在,只需要把正在拖放的对象,以及拖放操作中目标容器的id属性传给dropItemInto函数即可,而这个函数则定义在全局game对象的things属性里。
game.js文件需要改动的地方非常多,我们从头开始一点一点来分析。请用程序清单2.17中的代码把当前game.js文件里的内容全部都替换掉。
程序清单2.17 创建game.things属性
这段代码内容有点多。从结构上看,整个对象都包裹在一个自执行函数中,并作为这个函数的返回值赋给game对象的things属性。看看此函数最后所返回的那个对象,你也许会觉得这种写法看上去有些眼熟。函数所返回的对象会把公共方法映射到私有方法上面,比如,通过game.things.items即可引用函数内定义的那个items。在game.things的公共接口里含有三个方法,其中items方法可以返回items对象,get方法可以返回items中特定的道具对象(item),而dropItemInto方法则会执行dragDrop.js里面的许多复杂代码。
现在回到函数开头,我们可以看到,这段代码是以数据来表示道具对象的,在各种具体情况下需要执行的方法也是以数据形式放在道具对象里面的。比如以bat对象为例,该对象内部含有名为bat的name属性。而bat对象的effects属性里则含有一些附加的JSON数据,用以表示玩家将这根球棒拖放到其他道具之上时所产生的效果,其中拖放到player_inventory 的情况需要特殊处理。dino对象写在bat对象后面,其所包含的映射关系要比bat简单,因为涉及此物件的交互操作只有一种,那就是玩家也许会试着将其拖放至道具栏里。各种交互操作都可能产生三个效果:subject表示当玩家拖放道具时,道具原来所处的起始位置上会发生什么事情;object表示当玩家拖放道具时,将要拖放到的目标区域中会发生什么;而message则描述了玩家在执行当前这种交互操作时,幻灯片中所要显示的文本。
接下来是get方法,它会根据调用时所传入的name参数来返回与之相应的道具。
最后一个函数叫做dropItemInto,它有些复杂。该函数接受两个参数,分别是itemNode与target。sourceContext先与target相比较。如果二者相同(也就是拖放操作的起始地点与目标地点相同),那么函数就不用再执行下面的代码了。接下来的if,else if,else分支语句用于判定当前这个道具所产生的效果。而下一个if分支语句则用来判断effects里面是否定义了object属性,如果定义了,那么就找出此道具将给拖放操作的目标地点所带来的效果,并执行此效果。以if(!!effects.subject===true){开头的语句块也会通过类似的逻辑对拖放操作的起始地点运用相关效果,只不过这次检测的是subject属性,而非object属性。最后还有一个if语句块,它与前三个语句块均处于相同的嵌套层级上,这个语句块会调用game.slide.setText方法,将effects对象的message属性设置成当前这张幻灯片的event-text。dropItemInto函数的最后一行调用game.screen.draw方法,以便在道具更新之后重新绘制屏幕上的内容。
游戏中的大部分交互操作都由这个dropItemInto函数来驱动,讲过此函数后,我们接下来详细看看这个函数在实现其功能时所依赖的那些对象。game.js文件里的game.slide对象是用程序清单2.18中的代码来实现的。这段代码可以放在程序清单2.17的那段代码之后。
程序清单2.18 在game.js中创建game.slide对象
在定义game对象的slide属性时所采用的这种写法大家现在应该已经比较熟悉了。与前面几段代码相似,我们也可以先来看看return代码块中的公共接口里都定义了哪些方法,以此来了解该对象的用途。回到这段代码开头,我们看到inventory对象将每张幻灯片与其中所显示道具的名称关联起来,对于没有道具的幻灯片来说,其值为null。然后,定义了管理inventory所用的addItem与deleteItem函数,前者用于将item道具对象的名字放入inventory对象中,而后者则可以根据道具名称,将inventory对象中的相关值设为null。
接下来定义的这个findTextNode方法,可以在给定的幻灯片中找到class属性中含有event-text属性值的div元素。由于return代码块中并没有属性映射到这个方法上,所以它是个私有方法,仅能在game.slide对象内部使用。只有setText函数才会调用这个方法。
getInventory方法会根据给定的幻灯片,返回inventory对象里与之相应的道具。
setText方法会根据传入的message及slideId参数,将消息文本显示在对应的幻灯片中。如果没有提供slideId,则默认将文本显示在currentSlide上面。你若是用过支持默认参数值的编程语言,那么可能会认为此函数应该写成function(message,slideId=currentSlide()),这样的话,就可以在调用者没提供slideId的时候以默认参数值来调用了。在JavaScript里不能这么做。如果确实需要支持默认参数值的话,那么可以考虑将全部参数封装在一个对象里,然后传给函数,令函数来解析其中的参数,还有一种办法是,检测对应参数是否为null。
接下来就是刚才提到过好几次的那个currentSlide方法了。前面之所以要修改impress.js文件,就是想为实现此方法做准备。该函数会返回stepsTaken数组里的最后一个元素,而此元素正是impress.js在每次显示新幻灯片时,添加到数组中的那个元素。
slide对象中的最后一个函数名为draw。此函数开头再次使用刚才提到过的技巧,在调用者未提供slideId参数时,将其设为默认值currentSlide。然后在对应的幻灯片中寻找inventory-box元素,把inventory对象里的内容(可能是null,也可能是某个道具对象)加入其中。此函数还会根据inventory-box中是否包含图像,在其class属性列表里添加或移除empty属性值。
在本步骤中,还需要实现最后这个大对象,也就是playerInventory。请把程序清单2.19中的代码加入game.js。
程序清单2.19 创建playerInventory对象
这次的写法和原来相同,也是定义一个立即执行的函数,并将其返回的对象设为game对象的属性。代码中并无特别之处。与前几段范例代码一样,return语句块也将某些方法设置成可供外界调用的公共方法。现在回到代码开头,仔细分析一下这个对象的用途。
items是个用于存放道具的对象。你也许觉得使用数组比使用标准对象更为合适,不过,若是数组很大,那么想要检测其中是否含有某个元素就会相当耗时了,因为必须要搜寻整个数组才能判断元素在不在其中,反之,对于我们这里使用的items对象来说,只需要查询相关下标所对应的值是true还是false,即可判断出待查元素是否位于items之中了。由于我们使用了这套方案,所以,凡是游戏中有可能出现在玩家道具栏里的道具都得写在这里,并且要将其关联值设为false。
接下来是名为clearInventory的私有函数,它会将玩家道具栏中所有div元素里面的图像清除。在调用draw方法绘制道具栏之前,需要先调用此方法把待绘制的区域清理干净。此方法会向每个inventoryBox元素的class属性列表中添加empty属性值。
addItem与deleteItem方法很普通。它们只是把items对象中相关道具所对应的值设为true或false,并返回this.items而已。
draw函数首先将道具栏清空。然后设置for循环所使用的counter变量,这种for循环的写法本章前面未曾出现过。如果待遍历的不是数组而是普通的对象,那么最好采用for...in这种写法来轮番处理对象中的每个元素。循环内的代码需要引用特定的html元素,并从其class属性列表移除empty属性值,而完成这两项操作时都需要用到刚才定义的counter变量。
前面提到过的那个大对象已经写好了,现在还需要再加一个小对象。请将程序清单2.20这段代码加入game.js文件中,以便实现screen属性。
程序清单2.20 将screen属性加入game对象中
screen对象只是把playerInventory对象及slide对象里的draw函数封装起来而已。
这一步到此结束。用浏览器打开index.html文件,然后用球棒打恐龙,此时就会出现图2.3这样的画面了。
图2.3 用球棒打恐龙