游戏编程模式
《game programming patterns》 这是一本讲设计模式在游戏中的应用的书,我觉得作者讲得很好,有兴趣的小伙伴可以去看看原著。
我接下来的文章也会围绕这本书展开,使用React+JavaScript实现一些这本书上的简单例子。
第二天:命令模式
0.《命令模式》
MilK:昨天做的不错。
丁丁:那是当然~
MilK:哦豁?我看你有点膨胀嘛,再给你加一个需求,做做看?
丁丁:放马过来吧
MilK:移动了那么多次,能不能进行撤销操作
丁丁:撤销?简单,每一次操作都把数据保存下来,放在堆栈里。撤销的时候弹出堆栈顶部的数据就好了。
MilK:嗯哼?数据,什么数据?
丁丁:当然是所有角色的数据啊
MilK:那怎么行,你只对一个角色进行操作,却要储存所有角色的数据,要是进公司里你隔天就要被炒鱿鱼了
丁丁:... 那怎么办
MilK:我这里有一本《命令模式》,你先看看吧
命令模式的定义:
将一个请求封装为一个对象,从而使你可用不同的请求对客户进行参数化。对请求排队或记录请求日志,以及支持可撤销的操作。
好了,不说这么玄乎的概念了,直接看看例子吧:
1.配置输入
每个游戏中都有一块代码读取用户的输入————按钮按下,键盘敲击,鼠标点击,诸如此类,然后将其变为游戏中有意义的行为:
下面是一种简单的实现:(这里用上下左右移动代替原著上复杂的操作函数。是不是很熟悉,就是第一天中角色移动的代码,有关于角色移动的内容可以查看上一篇文章)
/**
* 键盘监听事件
* @param e
*/
onKeyDown = (e) => {
console.log(e.keyCode);
switch(e.keyCode) {
case 39:
goRight(this.focus);//向右移动
this.setState({});
break;
case 37:
goLeft(this.focus);//向左移动
this.setState({});
break;
case 38:
goUp(this.focus);//向上移动
this.setState({});
break;
case 40:
goDown(this.focus);//向下移动
this.setState({});
break;
default:
break;
}
(略...)
};
(略...)处省略的代码:
/**
* 向右移动函数
* @param focus
*/
function goRight(focus) {
focus.move(focus.left+focus.v,focus.top);
}
/**
* 向左移动函数
* @param focus
*/
function goLeft(focus) {
focus.move(focus.left-focus.v,focus.top);
}
/**
* 向上移动函数
* @param focus
*/
function goUp(focus) {
focus.move(focus.left,focus.top-focus.v);
}
/**
* 向下移动函数
* @param focus
*/
function goDown(focus) {
focus.move(focus.left,focus.top+focus.v);
}
看起来非常的方便,但是还是有一定的问题:一定要按上下左右才行吗,按ASDW不行么?许多游戏设置了允许玩家自行配置按键的功能。为了支持这一点,我们需要把goUp()和goDown()等函数的直接调用转化为可以变换的东西。因此,我们需要表示游戏行为的对象(命令/Command)。进入:命令模式。
我们定义一个命令的基础类:(当然也可以不用,不过最好还是规范一点)
function Command (){
this.execute=function () {};//执行
this.undo=function () {};//撤销
}
当你有接口只包含一个没有返回值的方法时,很可能可以使用命令模式。
然后我们为不同的命令定义相应的子类:(用到了上一章讲的继承)
/**
* 向右移动命令
* @constructor
*/
function GoRightCommand (){
this.execute = function goRight(focus) {//向右移动函数
focus.move(focus.left+focus.v,focus.top);
}
}
GoRightCommand.prototype = new Command();
/**
* 向左移动命令
* @constructor
*/
function GoLeftCommand (){
this.execute = function goLeft(focus) {//向左移动函数
focus.move(focus.left-focus.v,focus.top);
}
}
GoLeftCommand.prototype = new Command();
/**
* 向上移动命令
* @constructor
*/
function GoUpCommand (){
this.execute = function goUp(focus) {//向上移动函数
focus.move(focus.left,focus.top-focus.v);
}
}
GoUpCommand.prototype = new Command();
/**
* 向下移动命令
* @constructor
*/
function GoDownCommand (){
this.execute = function goDown(focus) {//向下移动函数
focus.move(focus.left,focus.top+focus.v);
}
}
GoDownCommand.prototype = new Command();
然后生产:
commands = {//定义好要用到的命令
goRightCommand : new GoRightCommand(),//向右移动命令
goLeftCommand : new GoLeftCommand(),//向左移动命令
goUpCommand : new GoUpCommand(),//向上移动命令
goDownCommand : new GoDownCommand(),//向下移动命令
};
看看生产出来的命令要怎么使用:
接下来想必你知道要怎么做了:按键和对应的命令可以自行设置,也可以让玩家设置(为了体现复用性,多加了键盘上AWDS的引用)
keyCommand = {//定义好要用到的按键和执行的命令
buttonRight_ : this.commands.goRightCommand,//键盘右,默认向右移动
buttonLeft_ : this.commands.goLeftCommand,//键盘左,默认向左移动
buttonUp_ : this.commands.goUpCommand,//键盘上,默认向上移动
buttonDown_ : this.commands.goDownCommand,//键盘下,默认向下移动
buttonD_ : this.commands.goRightCommand,//键盘D,默认向右移动
buttonA_ : this.commands.goLeftCommand,//键盘A,默认向左移动
buttonW_ : this.commands.goUpCommand,//键盘W,默认向上移动
buttonS_ : this.commands.goDownCommand,//键盘S,默认向下移动
};
键盘监听事件:
/**
* 键盘监听事件
* @param e
*/
onKeyDown = (e) => {
console.log(e.keyCode);
switch(e.keyCode) {
case 39://键盘右箭头被按下
this.keyCommand.buttonRight_.execute(this.focus);
this.setState({});
break;
case 37://键盘左箭头被按下
this.keyCommand.buttonLeft_.execute(this.focus);
this.setState({});
break;
case 38://键盘上箭头被按下
this.keyCommand.buttonUp_.execute(this.focus);
this.setState({});
break;
case 40://键盘下箭头被按下
this.keyCommand.buttonDown_.execute(this.focus);
this.setState({});
break;
case 68://键盘D被按下
this.keyCommand.buttonD_.execute(this.focus);
this.setState({});
break;
case 65://键盘A被按下
this.keyCommand.buttonA_.execute(this.focus);
this.setState({});
break;
case 87://键盘W被按下
this.keyCommand.buttonW_.execute(this.focus);
this.setState({});
break;
case 83://键盘S被按下
this.keyCommand.buttonS_.execute(this.focus);
this.setState({});
break;
default:
break;
}
};
好了,输入配置完成,效果和之前的完全一样,但是格式上显然更加符合规范了(多了个自定义按键的功能,由于不是重点,代码也有点多,本文中就不上代码了,可以当作课后作业哈哈)
如果你能看出这种写法相较之前写法的上的区别和好处,那么恭喜你,你合格了。接下来的部分作为奖励送给你~
2.撤销
撤销是命令模式广为人知的一种使用情况,一个命令可以做一件事情,那么同样可以撤销这件事情。在一些策略游戏中使用撤销,就可以回滚那些你不喜欢的操作。没有撤销功能,玩家可能会因为一个失误臭骂你的游戏(你看着办吧)。没有命令模式,实现撤销非常困难,有了它,就是小菜一碟。
你或许会很奇怪,为什么仅仅调用一个函数,还要new一个‘命令’对象出来?我在这里告诉大家,其实我们不止要new一个对象出来,我们还要new很多很多个一样的‘命令’对象出来,因为只有这样,才能把命令储存起来。现在我们要重写Command构造函数和button绑定的引用,解开你的疑惑(这才是命令模式真正的用法)。
关键!关键!关键!重点!重点!重点!⬇️
把命令执行的对象放在构造函数中,而不是执行函数中:(received代替focus,这样可以让new出来的Command与要操作的单位绑定,并且记录这个单位操作前的属性)
/**
* 向右移动命令
* @constructor
*/
function GoRightCommand (received){
this.received = received;
this.execute = function goRight() {//向右移动函数
this.received.move(this.received.left + this.received.v, this.received.top);
}
}
GoRightCommand.prototype = new Command();
/**
* 向左移动命令
* @constructor
*/
function GoLeftCommand (received){
this.received = received;
this.execute = function goLeft() {//向左移动函数
this.received.move(this.received.left - this.received.v, this.received.top);
}
}
GoLeftCommand.prototype = new Command();
/**
* 向上移动命令
* @constructor
*/
function GoUpCommand (received){
this.received = received;
this.execute = function goUp() {//向上移动函数
this.received.move(this.received.left, this.received.top - this.received.v);
}
}
GoUpCommand.prototype = new Command();
/**
* 向上移动命令
* @constructor
*/
function GoDownCommand (received){
this.received = received;
this.execute = function goDown() {//向下移动函数
this.received.move(this.received.left, this.received.top + this.received.v);
}
}
GoDownCommand.prototype = new Command();
button不再绑定一个实体,而是一个构造函数:
keyCommand = {//定义好要用到的按键和执行的命令
buttonRight_ : GoRightCommand,//键盘右,默认向右移动
buttonLeft_ : GoLeftCommand,//键盘左,默认向左移动
buttonUp_ : GoUpCommand,//键盘上,默认向上移动
buttonDown_ : GoDownCommand,//键盘下,默认向下移动
buttonD_ : GoRightCommand,//键盘D,默认向右移动
buttonA_ : GoLeftCommand,//键盘A,默认向左移动
buttonW_ : GoUpCommand,//键盘W,默认向上移动
buttonS_ : GoDownCommand,//键盘S,默认向下移动
};
// commands = {//定义好要用到的命令
// goRightCommand : new GoRightCommand(),//向右移动命令
// goLeftCommand : new GoLeftCommand(),//向左移动命令
// goUpCommand : new GoUpCommand(),//向上移动命令
// goDownCommand : new GoDownCommand(),//向下移动命令
// };
// keyCommand = {//定义好要用到的按键和执行的命令
// buttonRight_ : this.commands.goRightCommand,//键盘右,默认向右移动
// buttonLeft_ : this.commands.goLeftCommand,//键盘左,默认向左移动
// buttonUp_ : this.commands.goUpCommand,//键盘上,默认向上移动
// buttonDown_ : this.commands.goDownCommand,//键盘下,默认向下移动
// buttonD_ : this.commands.goRightCommand,//键盘D,默认向右移动
// buttonA_ : this.commands.goLeftCommand,//键盘A,默认向左移动
// buttonW_ : this.commands.goUpCommand,//键盘W,默认向上移动
// buttonS_ : this.commands.goDownCommand,//键盘S,默认向下移动
// };
重写键盘监听事件:直接new一个命令,然后执行execute这个命令
/**
* 键盘监听事件
* @param e
*/
onKeyDown = (e) => {
console.log(e.keyCode);
let command;
switch(e.keyCode) {
case 39://键盘右箭头被按下
command = new this.keyCommand.buttonRight_(this.focus);//new一个命令
command.execute();//执行这个命令
// this.keyCommand.buttonRight_.execute(this.focus);
this.setState({});
break;
(略...)
default:
break;
}
this.commandList.push(command);//把这个命令储存在数组中
};
看到这里,你或许就明白了:我们可以把这个命令储存在数组中
commandList = [];//定义一个数组专门储存命令
我们的undo函数终于要派上用场了:
/**
* 向右移动命令(无撤销)
* @constructor
*/
function GoRightCommand (received){
this.received = received;
this.execute = function goRight() {//向右移动函数
this.received.move(this.received.left + this.received.v, this.received.top);
}
}
GoRightCommand.prototype = new Command();
(其他命令略...)
/**
* 向右移动命令(有撤销)
* @constructor
*/
function GoRightCommand (received){
this.received = received;
this.beforeLeft = received.left;//记录原先的X坐标
this.beforeTop = received.top;//记录原先的Y坐标
this.execute = function goRight() {//向右移动函数
this.received.move(this.received.left + this.received.v, this.received.top);
}
this.undo = function () {//撤销函数
this.received.move(this.beforeLeft, this.beforeTop);
}
}
GoRightCommand.prototype = new Command();
(其他命令略...)
最后添加上撤销事件:
/**
* 键盘监听事件
* @param e
*/
onKeyDown = (e) => {
console.log(e.keyCode);
let command = null;//定义一个command
switch(e.keyCode) {
(略...)
case 90://按Z撤销
let usedCommand = this.commandList.pop();//从command数组中取出最新执行的命令
usedCommand.undo();//执行撤销
this.setState({});
break ;
default:
break;
}
if(command!==null){//如果command不为空
this.commandList.push(command);//把这个命令储存在数组中
}
};
效果:
到此为止,撤销功能完全实现咯
3.重做与优化
有撤销就一定有重做,想必现在重做对你来说已经非常简单了:
remakeList = [];//定义一个数组专门储存撤销的命令
/**
* 键盘监听事件
* @param e
*/
onKeyDown = (e) => {
console.log(e.keyCode);
let command = null;//定义一个command
switch(e.keyCode) {
(略...)
case 90://按Z撤销
if(this.commandList.length>0){
command = this.commandList.pop();//从command数组中取出最新执行的命令
command.undo();//执行撤销
this.remakeList.push(command);//把命令添加到重做列表
command = null;//把command置空
this.setState({});
}
break ;
case 16://按shift重做
if(this.remakeList.length>0){
command = this.remakeList.pop();//从remake数组中取出最新执行的命令
command.execute();//执行重做
this.setState({});
}
break ;
}
if(command!==null){//如果command不为空
this.commandList.push(command);//把这个命令储存在数组中
}
};
为了防止命令储存的太多导致占用内存,限制一下commandList和remakeList的大小:
if(this.commandList.length>100){//限制存储命令的数量
this.commandList.shift();//删除最早的命令
}
if(this.remakeList.length>100){//限制存储命令的数量
this.remakeList.shift();//删除最早的命令
}
完美~
4.闭包
作为一个前端老司机,怎么能不用上闭包,但是因为闭包在JS中太好用了,不适合用来讲解命令模式,前面才使用构造的方式来讲,毕竟其他语言可没有闭包的功能。
不理解闭包的小伙伴可以好好了解一下咯(划重点,要考)
先来看看闭包函数和构造函数的区别:
/**
* 构造的方式
*/
/**
* 向右移动命令
* @constructor
*/
function GoRightCommand (received){
this.received = received;
this.beforeLeft = received.left;//记录原先的X坐标
this.beforeTop = received.top;//记录原先的Y坐标
this.execute = function goRight() {//向右移动函数
this.received.move(this.received.left + this.received.v, this.received.top);
};
this.undo = function () {//撤销函数
this.received.move(this.beforeLeft, this.beforeTop);
};
}
GoRightCommand.prototype = new Command();
command = new GoRightCommand(received);//new出来一个命令
/**
* 闭包的方式
*/
/**
* 向右移动命令
* @constructor
*/
function goRightCommand (received){
let beforeLeft = received.left,//记录原先的X坐标
beforeTop = received.top;//记录原先的Y坐标
return {
execute : function goRight() {//向右移动函数
received.move(received.left + received.v, received.top);
},
undo : function () {//撤销函数
received.move(beforeLeft, beforeTop);
}
}
}
command = goRightCommand(received);//直接return出来一个命令
两种实现方式都可以,闭包看起来方便一点。
一个函数的return中还有函数,就形成了闭包。
JS闭包的特性:如果函数(这里指goRightCommand)内有函数(这里指execute和undo)被return出来的话,这个函数内部的内存(这里指beforLeft和beforeTop)就不会被回收,直到所有指向它的指针(这里指command)全部消失,这个函数的内存才会被回收。也就是说,设置command = null,这一段内存就自动回收了。(这里涉及到JS内存回收机制以及防止内存泄漏的办法,前端面试官很有可能会问,大家可以百度自学)
Bob Nystrom有说到:在JS中,使用闭包来实现命令模式无疑是最好的解决方案。
好咯,学习React小游戏开发第二天,有没有觉得头脑发热呢~之后会学习更多的应用场景,写更有趣的demo,所有的demo都能在git上找到嗷。有什么想要实现的小游戏或者有趣想法,欢迎在评论处留言,欢迎交流。