想用React写游戏第二天:命令模式

589 阅读10分钟

游戏编程模式

《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上找到嗷。有什么想要实现的小游戏或者有趣想法,欢迎在评论处留言,欢迎交流。

Github源码