想用React写游戏第三天:享元模式

433 阅读7分钟

第三天:享元模式

森林

MilK:丁丁,有人反应你做的东西太丑了。

丁丁:u1s1,确实

MliK:那你知道该怎么做了吗?

丁丁:嗯,知道...等等,今天还有什么好用的方法吗?我可不想等我做完之后重新修改

MliK(一脸嫌弃):没有,自己想去

丁丁:好吧

MilK:快去加班,弄一个好看点的场景出来看看

丁丁:那就做一个森林吧

迷雾散尽,露出了古朴庄严的森林。古老的松树,编织成绿色的海洋。阳光在树梢之间跳动,从树干间远眺,远处的森林渐渐隐去...

这是游戏开发者梦想的超凡场景,这样的场景通常由一个模式支撑着,它低调至极,以至于我们经常使用它而不知道它叫什么:享元模式。

一句话就能描述一片巨大的森林,但是在cavans画板中做这一片森林就完全是另一回事了,当屏幕上需要显示一个森林时,图形程序员看到的是每秒需要送到GPU六十次的百万多边形。(一般人眼能识别极限频率是每秒60帧)

成千上万的树,每棵都由上千的多边形组成。就算有足够的内存描述这片森林,在渲染的过程中CPU和GPU的压力也太大了。

每棵树都有一些自己的属性:

  • 定义树干,树枝和树叶形状的多边形
  • 树皮和树叶的纹理
  • 在森林中树的位置和朝向
  • 大小和颜色之类的调节参数,让每棵树看起来与众不同
  • 等等

多边形和纹理的体积非常大,把这么多数据在1/60秒内交给GPU处理实在是太过分了。幸运的是,有一种老办法来处理它。关键点在于,哪怕森林里有千千万万的树,它们大多数长得一摸一样,它们使用了相同的形状和纹理。这意味着这些树的实例大部分字段是一样的。

你要么是疯了,要么是亿万富翁,才能让你的UI给整个森林的每一棵树建立一个独立的模型。

所有相同的属性和参数,我们都可以放在同一个模型中,让其他单元共享。

像IDEA中有代码查重功能和自动修复功能,用封装的方式,尽量减少重复代码的出现(也可以理解为享元)。作为一个程序员,想必这种思想你已经烂熟于心了。当然,除了自己代码,你还要考虑CPU和内存的重复使用,如果你的代码能有效的减少计算机的工作量的话,那你就步入资深程序员了。

style和src

作为一个前端程序员,我们不妨看看前端哪里有用到“享元模式”。

哈哈,想必你看标题就已经知道了。在css中,我们常用的class,就利用了享元的思想。另外,当你在用src获取资源的时候,即使页面上多次使用同样的链接,实际上浏览器也只会加载一次,也是享元的思想(第一次调用之后,这个资源就会存在缓存池中,浏览器会优先从缓存池中寻找静态资源,缓存池中没有再从互联网获取)。

<style type="text/css">
    .class1{background-color: #FFFFEE;color: #000000}
</style>
<div >
    <div class="class1">1</div>
    <div class="class1">2</div>
    <div class="class1">3</div>
    <img src="img/1.jpg">
    <img src="img/1.jpg">
    <img src="img/1.jpg">
</div>

这里的class1和1.jpg就是被共享的元。

看到这里你会说:这也太简单了吧,有什么好讲的~

设计模式注重的是我们时刻不要忘记这种思想,不管在写什么代码的时候,都不要忘记这种思路。(享元其实是对象池,线程池,连接池,常量池等...是资源共享的高级应用,也有人说: 享元模式 !== 资源共享)

好吧,不装了,我摊牌了,我也觉得没什么好讲的,所以这一章我们就专心打造我们的“森林”吧。

透视

这是一个美术上的东西,我高中的时候想要艺考还特地向MilK拜师学过。现在我们就要用计算机简单模拟一下透视的效果。

值得注意的是我们眼睛的位置,屏幕的位置,以及物体实际的位置。我们假设位置关系是这样的:

世界的坐标系(x, y, z):

  • 眼睛的位置(0, 200, 0)
  • 屏幕的位置(x, y, 200)
  • 物体中心点的位置(0,90,700)
  • 物体的位置(-50, 0, 700)~(50, 180, 700)

通过相似三角形的原理

易证,屏幕中的坐标系(x,y):

  • 物体中心点在屏幕上的投影:(物体X坐标0 * (屏幕距离200 / 物体距离700), 眼睛高度200 + (物体高度90 - 眼睛高度200) * ( 屏幕距离200 / 物体距离700)})
  • => (0, 168.5)
  • 物体的在屏幕上的投影:(-50 * (200 / 700), 200 + (0 - 200) * (200 / 700)) ~ (50 * (200 / 700), 200 + (180 - 200) * (200 / 700))
  • => (-14.28, 142.85) ~ ( 14.28, 194.28)

得出公式:

  • 透视缩放比例 = 屏幕z坐标 / 物体z坐标
  • 物体中心点y坐标 = 眼睛y坐标 +(物体y坐标 - 眼睛y坐标)* 透视缩放比例
  • 物体中心点x坐标 = 物体x坐标 * 透视缩放比例
  • 物体中心点x坐标 = 眼睛x坐标 +(物体x坐标 - 眼睛x坐标)* 透视缩放比例 (眼睛x坐标不为0的话)

所以为了实现透视的效果,我们必须定义好屏幕到眼睛的距离和眼睛的位置参数:

const param = {
    screenZ : 200,//屏幕到眼睛的距离
    eyeY : 200,//眼睛的高度
    eyeX : 0,//眼睛的x坐标
};

编写计算css的函数:(直接写在unit的构造函数中,以屏幕中心点为x和y的零点)

function CreateUnit (unit){
    this.width = unit.width || 1000;//宽度
    this.height = unit.height || 1000;//高度
    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.move = function (left,top) {//移动函数
        this.left = left;
        this.top = top;
    }
    this.updateStyle = function () {//根据xyz坐标,计算样式
        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;
    }
}
export default CreateUnit;

修改移动命令:(左右移动是改变x坐标,上下移动是改变z坐标)

/**
 * 向右移动命令
 * @constructor
 */
export function goRightCommand (received){
    let beforeX = received.x;//记录原先的X坐标
    return {
        execute : function goRight() {//向右移动函数
            received.x = received.x + received.v;
            received.updateStyle();
        },
        undo : function () {//撤销函数
            received.x = beforeX;
            received.updateStyle();
        }
    }
}

/**
 * 向上移动命令
 * @constructor
 */
export function goUpCommand (received){
    let beforeZ = received.z;//记录原先的Z坐标
    return {
        execute : function goRight() {//向右移动函数
            received.z = received.z + received.v;
            received.updateStyle();
        },
        undo : function () {//撤销函数
            received.z = beforeZ;
            received.updateStyle();
        }
    }
}

定义好Tree的类和Img组件:

import CreateUnit from "@/pages/baseComponent/Unit";
import treeImg from "@/img/tree.png"

function Tree (tree){
    let flag=tree==null;
    this.name=flag?"tree":tree.name||"tree";
    this.src=flag?treeImg:tree.src||treeImg;
}

Tree.prototype = new CreateUnit({});

export default Tree;
class ImgUnit extends Component{
    render() {
        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>;
    }
}

接下来就是激动人心的效果时间:

多放几棵可爱的松树:
怎么样,看起来还不错吧

优化

其实这样的代码还是有问题的:

发现问题了吗,数组后面的树会永远在前面的树上面显示,所以我们在渲染之前还需要对数组进行一次排序,让近的物体在远的物体前面:

/**
 * REACT生命周期主函数,用于渲染页面
 * @returns {*}
 */
render() {
    this.treeList.sort(function (a,b) {//重新排序
        return b.z - a.z;
    });
    return (
        <div className = {"game3"}>
            {
                this.treeList.map((item,i)=>{
                    return <ImgUnit
                        key = {i}
                        onClick = {()=>{
                            this.setFocus(item);
                        }}
                        unit = {item}
                    />
                })
            }
        </div>
    );
}

效果:

噢,不过每一次渲染都要重新排序真不是一个好办法,能不能在某个单元Z轴更改的同时修改顺序呢?

答案是肯定的,我就有一个好办法,不过在这里我先卖个关子,之后在碰撞检测的部分会详细说明,大家可以先自己思考思考,欢迎在评论下方交流哦~

加班

你做的森林一点也不好看!改好看一点!

Github源码