想用React写游戏第四天:状态模式

485 阅读5分钟

第四天:状态模式

前言

这一章的内容比较多。它表面上关于状态模式,但我无法只讨论它和游戏,而不涉及更加基础的有限状态机(FSMs)。但是一旦讲了这个,我发现我也想介绍层次状态机和下推自动机。

如果你没有听说过状态机,不要难过。虽然在AI和编译器程序中很出名,但它在其他编程圈就没那么知名了。我认为应该有更多人知道它,所以在这里我将其运用在不同的问题上。

这些状态机术语来自人工智能时代。在五十年代到六十年代,很多AI研究专注于语言处理。很多现在用于分析程序语言的技术在当时是发明出来分析人类语言的。

状态机

死神VS火影

丁丁(拿着MilK的照片):MliK去参加竞赛了,要好几个月才能回来。

MliK的照片:。。。

丁丁:我是不是应该趁机偷偷学习,等MilK回来让他刮目相看。

MliK的照片:。。。

丁丁:要不做一个死神VS火影?

MilK的照片:。。。

丁丁:说干就干,就从人物动作开始!

跳跃动作

首先我们来模拟一个跳跃动作。为了让跳跃显得更真实,我们引入了速度和加速度的概念。设置一个起跳速度v,每一次位移这个v的距离,位移后 v - 1 ,直到 v < -v 停止,角色回到原点。

在CreateUnit中:

this.jump = unit.jump || 20;//跳跃能力系数

键盘命令表中:

buttonSpace_ : jumpCommand //键盘空格,默认跳跃,

在键盘监听事件中:

case 32://空格键被按下
    command = this.keyCommand.buttonSpace_(this.focus);
    command.execute();
    break;

commands.js:

/**
 * 跳跃命令
 * @constructor
 */
export function jumpCommand (received){
    let beforeY = received.y;//记录原先的Y坐标
    return {
        execute : function jump() {//向下移动函数
            console.log("jump");
            let v = received.jump;
            let timer = setInterval(()=>{
                received.y = received.y + received.v * v / 100;
                received.updateStyle();
                v -= 1;
                if(v < -received.jump){
                    clearInterval(timer);
                }
            },10);

        },
        undo : function () {//撤销函数
            received.y = beforeY;
            received.updateStyle();
        }
    }
}

updateStyle在上一章中有讲过,是更新组件样式的函数。更新完一定要重新渲染页面,对吧?还记得之前是怎么渲染的吗?是在执行移动命令的同时在主页面进行setState()。这样会导致一个问题,只移动了一个物体,却需要重新渲染一整个页面的所有物体,这样显然是不合理的,这次我们采用更优化的方式:只对子组件进行setState():

CreateUnit:

function CreateUnit (unit){
    this.component = null;//绑定的组件
    this.width = unit.width || 100;//宽度
    this.height = unit.height || 100;//高度
    this.left = unit.left || 100;//最终渲染X坐标
    this.top = unit.top || 100; //最终渲染Y坐标
    this.proportion = unit.proportion || 1;//缩放比例
    this.x = unit.x || 0;//虚拟X坐标
    this.y = unit.y || 0;//虚拟Y坐标
    this.z = unit.z || 1700;//虚拟Z坐标
    this.v = unit.v || 100;//移动速度
    this.jump = unit.jump || 20;//跳跃能力系数
    this.move = function (left,top) {//移动函数
        this.left = left;
        this.top = top;
    };
    this.updateStyle = function () {//计算渲染样式
        if(this.z < param.screenZ){
            this.z = param.screenZ;
        }
        this.proportion  = param.screenZ / this.z;
        this.left = window.innerWidth / 2 + param.eyeX + (this.x - param.eyeX) * this.proportion;
        this.top = window.innerHeight / 2 - param.eyeY - (this.y - param.eyeY) * this.proportion;
        this.component !== null ? this.component.setState({}) : null;//如果已经绑定了组件就渲染页面
    };
    this.bindComponent = function (component) {//组件绑定函数
        this.component = component;
    }
}

子组件代码:

class ImgUnit extends Component{

    /**
     * 构造函数
     * @param props
     */
    constructor(props) {
        super(props);
        this.props.unit.bindComponent(this);
    }

    /**
     * 生命周期函数,props修改时触发
     * @param nextProps
     */
    componentWillReceiveProps(nextProps) {
        nextProps.unit.bindComponent(this);
    }

    render() {
        console.log(1);
        const {onClick,unit} = this.props;
        return <div
            onClick = {onClick}
        >
            <img
                src = {unit.src} alt={"img"}
                style = {{
                    width:unit.width * unit.proportion,
                    height:unit.height * unit.proportion,
                    left:unit.left - unit.width * unit.proportion / 2,
                    top:unit.top - unit.height * unit.proportion / 2,
                    position:"absolute",
                    textAlign:"center",
                    lineHeight:unit.height+"px"
                }}
            />
        </div>;
    }
}

看懂了吗?耐心看懂代码中何时进行了组件绑定,这样会有什么意义。注意this.props.unit.bindComponent(this)和updateStyle中的this.component.setState({})的关系。这样便能在unit修改时只对ImgUnit这个组件进行渲染,而不是一整个页面。

非常抱歉,明明只是一个简单的跳跃效果却花了大时间讲了如何渲染。我们直接看效果吧:

啊哈,会跳的树~

引入状态

但是,你发现漏洞了吗?

这种跳跃方式可以在空中无限跳跃——角色在空中时疯狂按空格,他就会浮空,显然这样的设定是不可取的。

简单的修复方法是在跳跃命令中给unit添加isJumping字段,追踪它跳跃的状态:

/**
 * 跳跃命令
 * @constructor
 */
export function jumpCommand (received){
    let beforeY = received.y; //记录原先的Y坐标
    return {
        execute : function jump() { //跳跃函数
            console.log("jump");
            if(!received.isJumping){ //isJumping字段判断是否已经在跳跃
                received.isJumping = true;
                let v = received.jump; //设置单位的起跳速度等于自身的弹跳系数
                let timer = setInterval(() => {
                    received.y = received.y + received.v * v / 100; //单位的y坐标随速度变化
                    received.updateStyle(); //重新渲染
                    v -= 1; //模拟重力,速度越来越小
                    if(v < -received.jump){ //速度等于负起跳速度时相当于回到原点,此时停止跳跃事件
                        received.isJumping = false;
                        clearInterval(timer);
                    }
                },10);
            }
        },
        undo : function () {//撤销函数
            received.y = beforeY;
            received.updateStyle();
        }
    }
}

这就是引入状态isJumpin之后的效果。

关键点来了,我们不妨想象一下:人物的动作不止跳跃一个,拿死神VS火影来说,还有攻击,二连跳,防御,跑步等等动作。跳跃时需要一个isJumping状态,攻击时需要一个isAttacking状态,二连跳的时候需要一个isDoubleJumping状态,移动时需要一个isMoving状态?攻击的时候不能走路,攻击的时候不能跳跃,跳跃的时候攻击会触发跳斩,甚至释放某个技能的时候也要判断自身是否处于跑步、腾空或者是防御状态,以此来触发不同的效果。

这么多个状态我们要如何进行管理?每一次执行命令之前都要进行这么多状态的判断吗?复杂的分支和可变状态,是一种易错代码。几十种状态的判断语句,注定会让程序员焦头烂额,并且稍不留神就会留下奇奇怪怪的BUG。

那些你崇拜的、看上去永远能写出完美代码的程序员并不是超人。相反,他们有那种代码容易出错的直觉,然后避开。

有限状态机前来救援

经历了以上的思考之后,把桌子扫空,只留下纸笔,我们开始画流程图。你给英雄每一件能做的事情都画了一个盒子:从简单的站立,跳跃,防御,速降四个动作开始。当角色能响应按键的状态时,你从那个盒子画出一个箭头,标记上按键,然后连接到它变的状态上。

恭喜,你刚刚建好了一个有限状态机(Finit state machine, FSM)。它来自计算机科学的分支自动理论,那里有很多著名的数据结构,包括著名的图灵机。FSMs是其中最简单的成员。

FSM的特点:

  • 你拥有状态机所有可能状态的集合。 在我们的例子中,是站立,防御,跳跃和速降。
  • 状态机同时只能存在一个状态。 英雄不能同时处于跳跃和站立状态。
  • 一连串的输入或者事件被发送给状态机。 在我们的例子中,就是按键按下松开和落地事件。
  • 每个状态都有一系列的转移,每个转移与输入和另一状态有关。 当输入进来,如果他与当前状态的某个转移相匹配,机器转换为所指向的状态。举个例子,在站立状态时,输入空格转换为跳跃。如果输入在当前状态下没有转移,输入就被忽视。

最近学了好多新知识,想重构一下我的代码,慢慢更新ing...