游戏地图主要功能实现

游戏地图功能主要分为三块:

  • 地图纹理
  • 地图阻挡
  • 小地图
  • 镜头跟随

一 地图纹理

1 原始实现

地图最基础的做法,是出一张图,然后拖入到场景中;

ori

2 动态加载

但这种做法只适合有一张主城地图的界面游戏,若有多张地图,在场景中设置的地图,可能不是游戏中所需的,所以需要动态加载地图。

    loadMap(mapId) {
        this.mapId = mapId;

        let node = new cc.Node()
        node.setAnchorPoint(0, 0)
        node.parent = this.mapRoot;
        this.spMap = node.addComponent(cc.Sprite)

        this.stm = new Date().getTime()
        let url = "map/" + this.mapId
        cc.resources.load(url, cc.SpriteFrame, (error: Error, spf: cc.SpriteFrame) => {
            if (error) {
                console.error(error.message || error)
            }
            this.spMap.spriteFrame = spf;
            console.log("loading cost time:", new Date().getTime() - this.stm, "ms")
        })
    }

3 分块加载

一般地图都会很大,有十几屏,甚至几十屏,为了后续的优化,先将地图切成地图块,然后分开加载,地图纹理的切分工具,参考之前的博文

    loadMap(mapId) {
        this.mapId = mapId;
        this.loadConfig()
    }

    loadConfig() {
        this.stm = new Date().getTime()
        let url = "map/" + this.mapId + "/" + this.mapId
        cc.resources.load(url, cc.JsonAsset, (error: Error, jsonAsset: cc.JsonAsset) => {
            if (error) {
                console.error(error.message || error)
            }
            this.config = jsonAsset.json

            let node = new cc.Node()
            node.setAnchorPoint(0, 0)
            node.parent = this.mapRoot;

            let size = this.config.size
            node.width = size[0]
            node.height = size[0]
            this.ndMap = node;
            
            this.loadTexure()
        })
    }

    loadTexure() {
        let cnt = this.config.cnt
        let pos
        for (let x = 0; x < cnt[0]; x++) {
            for (let y = 0; y < cnt[1]; y++) {
                pos = [x, y]
                this.loadingQueue.push(pos)
            }
        }
        this.loadTile()
    }

    loadTile() {
        if (this.loadingQueue.length === 0) {
            this.onLoadFinish()
            return
        }

        let pos = this.loadingQueue.pop()
        let tileName = "tile_" + pos[0] + "_" + pos[1]
        let url = "map/" + this.mapId + "/" + tileName
        cc.resources.load(url, cc.SpriteFrame, (error: Error, spf: cc.SpriteFrame) => {
            if (error) {
                console.error(error.message || error)
            }
            let node = new cc.Node()
            node.setAnchorPoint(0, 0)
            node.parent = this.ndMap
            node.name = tileName
            let tile = this.config.tile
            node.x = pos[0] * tile[0]
            node.y = pos[1] * tile[1]
            let sp = node.addComponent(cc.Sprite)
            sp.spriteFrame = spf;

            this.loadTile()
        })
    }

    onLoadFinish() {
        console.log("loading time:", new Date().getTime() - this.stm, "ms")
    }

地图分块之后,需要一个配置文件,保存原始地图的尺寸、地图块尺寸、地图块的总数量等。

4 只加载可视地块

单纯将地图分块 IO 耗时会更长,占用的内存也一样,实际上是个负优化。在分块的基础上,根据玩家当前位置,判断哪些地图块可见,只把可见的地图块加载进内存,这样能减少内存的占用。
可视区域如图:
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-ncH0e2Ku-1598854597756)(./visible_rect.png)]

ndMapRoot 的大小是通过 widget 设置的四个边距都为0,刚好是屏幕的大小,即 ndMapRoot 的宽高,就是宽高。ndMap 的父节点是 ndMapRoot,通过 ndMap 的坐标 (x, y),可以得到可视区域的坐标为 (-x, -y),所以可见的矩形区域即为 (-x, -y, ndMapRoot.width, ndMapRoot.height)

    // 获取可视区域矩形
    // mapRoot 的大小通过 widget 设置边距都为 0,所以 mapRoot 刚好就是屏幕的大小
    getVisibleRect() {
        return new cc.Rect(-this.ndMap.x, -this.ndMap.y, this.mapRoot.width, this.mapRoot.height)
    }

    // 判断坐标位置的图块是否可见
    isTileVisible(x, y) {
        let tile = this.config.tile
        let tileW = tile[0]
        let tileH = tile[1];
        let tileBound = new cc.Rect(x * tileW, y * tileH, tileW, tileH);

        let visibleBound = this.getVisibleRect();
        return tileBound.intersects(visibleBound);
    }

    loadTexure() {
        let cnt = this.config.cnt
        let pos
        for (let x = 0; x < cnt[0]; x++) {
            for (let y = 0; y < cnt[1]; y++) {
                if (this.isTileVisible(x, y)) {
                    pos = [x, y]
                    this.loadingQueue.push(pos)
                }
            }
        }
        this.loadTile()
    }

5 地图背景

地图分块加载之后,因为加载是异步加载,在移动的过程中,会看到还没有加载出来的地块位置是黑色的。为了解决这个问题,可以首先加载一个小地图纹理,放大作为背景。因为小地图的资源小,加载比较快,但是放大之后会变模糊。在移动的过程中,会呈现出有模糊变清晰的过程。

    loadMiniMap() {
        let url = "map/" + this.mapId + "/" + this.mapId
        cc.resources.load(url, cc.SpriteFrame, (error: Error, spf: cc.SpriteFrame) => {
            if (error) {
                console.error(error.message || error)
            }
            let miniNode = new cc.Node()
            let sprite = miniNode.addComponent(cc.Sprite)
            sprite.spriteFrame = spf
            sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM
            miniNode.width = this.ndMap.width
            miniNode.height = this.ndMap.height
            miniNode.zIndex = 0
            miniNode.setAnchorPoint(0, 0)
            miniNode.setPosition(0, 0)
            miniNode.parent = this.ndMap
            this.loadTexure()
        })
    }

二 地图阻挡

阻挡分为两块:

  1. 生成阻挡信息
  2. 寻路

1 生成阻挡信息

地图的阻挡信息,是通过把整个地图划分成网格,在网格上标记不同的数值,表示当前网格的地图信息。参考之前的博文

2 寻路

寻路使用的是 A* 寻路,网上有很多教程,之前的博文也有用到。

三 小地图

小地图功能:

  1. 显示纹理
  2. 显示可视区域
  3. 交互

1 显示纹理

在场景中,加载添加一个精灵,作为小地图。在加载地图背景时,已经把小地图加载进游戏,赋值给小地图即可。

2 显示可视区域

可视区域在加载地图块时已经有函数可以获得,只要将获得的可是矩形区域,按比例映射到小地图,即可获得在小地图上的矩形区域,然后通过 Graphics 组件,将矩形区域绘制出来即可,Graphics文档,Graphics 组件和 Sprite 组件,都是渲染组件不能同时出现在一个节点,因此需要再另外创建一个节点。

3 交互

一般游戏都可以点击小地图进行寻路,可以监听小地图节点的触摸事件,获取到交互节点位置,通过缩放比例,映射的地图的位置,即寻路的目标位置。

    loadMiniMap() {
        let url = "map/" + this.mapId + "/" + this.mapId
        cc.resources.load(url, cc.SpriteFrame, (error: Error, spf: cc.SpriteFrame) => {
            if (error) {
                console.error(error.message || error)
            }
            // 小地图
            this.spMiniMap.spriteFrame = spf
            let nd = this.spMiniMap.node
            let scale = this.mapRoot.width * 0.2 / this.ndMap.width
            nd.width = scale * this.ndMap.width
            nd.height = scale * this.ndMap.height
            nd.getComponent(cc.Widget).updateAlignment()

            // 地图背景节点
            let miniNode = new cc.Node()
            let sprite = miniNode.addComponent(cc.Sprite)
            sprite.spriteFrame = spf
            sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM
            miniNode.width = this.ndMap.width
            miniNode.height = this.ndMap.height
            miniNode.zIndex = 0
            miniNode.setAnchorPoint(0, 0)
            miniNode.setPosition(0, 0)
            miniNode.parent = this.ndMap
            this.updateMiniVisible()
            this.loadTexure()
        })
    }

    updateMiniVisible() {
        let scale = this.spMiniMap.node.width / this.ndMap.width
        if (this.graphic === null) {
            let nd = new cc.Node()
            nd.setAnchorPoint(0, 0)
            nd.setPosition(0, 0)
            nd.width = this.spMiniMap.node.width
            nd.height = this.spMiniMap.node.height
            nd.parent = this.spMiniMap.node
            this.graphic = nd.addComponent(cc.Graphics)
            this.graphic.strokeColor = cc.Color.RED
            nd.on(cc.Node.EventType.TOUCH_START, (event)=>{
                let ori = nd.convertToNodeSpaceAR(event.getLocation());
                let pos = cc.v2(Math.round(ori.x / scale), Math.round(ori.y / scale))
                console.log(pos.x, pos.y) // 输出获取到的坐标
            });
        }
        let rect: cc.Rect = this.getVisibleRect()
        this.graphic.rect(
            Math.floor(rect.x * scale),
            Math.floor(rect.y * scale),
            Math.floor(rect.width * scale),
            Math.floor(rect.height * scale),
        )
        this.graphic.stroke();
    }

四 镜头跟随

镜头跟随也是有两种方法可以实现移动地图和移动相机两种方案,很多传统的2D游戏,没有镜头的概念,只能通过移动地图的方式实现。当角色移动时,实际上是角色和地图的相对位置发生变化,相对位置发生改变可以修改角色的位置,也可以反向修改地图的位置。角色始终要停留在屏幕的中心位置,角色的位置不能改变,只能反向修改地图的位置。

    median(min, value, max) {
        return Math.min(Math.max(min, value), max);
    }
    onActorMove(x, y){
        let viewSize = this.mapRoot
        this.ndMap.x = this.median(viewSize.width - this.ndMap.width, viewSize.width / 2 - x, 0)
        this.ndMap.y = this.median(viewSize.height - this.ndMap.height, viewSize.height / 2 - y, 0)
        this.updateMiniVisible()
        this.loadTexure()
    }

注意不要让地图移除屏幕出现黑边

五 抽离组件

地图相关同能是一个单独的模块,可以提取成一个单独的组件,方便在其他场景中使用。

const { ccclass, property } = cc._decorator;

@ccclass
export default class MapComp extends cc.Component {
    stm: number = 0
    mapId: string = ""
    config: object = null
    ndMap: cc.Node = null
    loadingQueue = []
    graphic: cc.Graphics = null
    spMiniMap: cc.Sprite = null
    loadFinishCb: Function = null

    loadMap(mapId, cb) {
        this.mapId = mapId
        this.loadFinishCb = cb
        this.createMiniMap()
        this.loadConfig()
    }

    createMiniMap() {
        let miniNode = new cc.Node()
        miniNode.zIndex = 2
        miniNode.setAnchorPoint(0, 0)
        miniNode.setPosition(0, 0)
        miniNode.parent = this.node.parent
        let widget = miniNode.addComponent(cc.Widget)
        widget.isAlignTop = true
        widget.top = 0
        widget.isAlignRight = true
        widget.right = 0
        this.spMiniMap = miniNode.addComponent(cc.Sprite)
    }

    loadConfig() {
        this.stm = new Date().getTime()
        let url = "map/" + this.mapId + "/" + this.mapId
        cc.resources.load(url, cc.JsonAsset, (error: Error, jsonAsset: cc.JsonAsset) => {
            if (error) {
                console.error(error.message || error)
            }
            this.config = jsonAsset.json
            let size = this.config.size
            this.node.width = size[0]
            this.node.height = size[0]

            this.loadMiniMap()
        })
    }

    loadMiniMap() {
        let url = "map/" + this.mapId + "/" + this.mapId
        cc.resources.load(url, cc.SpriteFrame, (error: Error, spf: cc.SpriteFrame) => {
            if (error) {
                console.error(error.message || error)
            }
            // 小地图
            this.spMiniMap.spriteFrame = spf
            let nd = this.spMiniMap.node
            let scale = this.node.parent.width * 0.2 / this.node.width
            nd.width = scale * this.node.width
            nd.height = scale * this.node.height
            nd.getComponent(cc.Widget).updateAlignment()

            // 地图背景节点
            let miniNode = new cc.Node()
            let sprite = miniNode.addComponent(cc.Sprite)
            sprite.spriteFrame = spf
            sprite.sizeMode = cc.Sprite.SizeMode.CUSTOM
            miniNode.width = this.node.width
            miniNode.height = this.node.height
            miniNode.zIndex = 0
            miniNode.setAnchorPoint(0, 0)
            miniNode.setPosition(0, 0)
            miniNode.parent = this.node
            this.updateMiniVisible()
            this.loadTexure()
        })
    }

    updateMiniVisible() {
        let scale = this.spMiniMap.node.width / this.node.width
        if (this.graphic === null) {
            let nd = new cc.Node()
            nd.setAnchorPoint(0, 0)
            nd.setPosition(0, 0)
            nd.width = this.spMiniMap.node.width
            nd.height = this.spMiniMap.node.height
            nd.parent = this.spMiniMap.node
            this.graphic = nd.addComponent(cc.Graphics)
            this.graphic.strokeColor = cc.Color.RED
            nd.on(cc.Node.EventType.TOUCH_START, (event)=>{
                let ori = nd.convertToNodeSpaceAR(event.getLocation());
                let pos = cc.v2(Math.round(ori.x / scale), Math.round(ori.y / scale))
                this.onActorMove(pos.x, pos.y)
            });
        }
        let rect: cc.Rect = this.getVisibleRect()
        this.graphic.clear()
        this.graphic.rect(
            Math.floor(rect.x * scale),
            Math.floor(rect.y * scale),
            Math.floor(rect.width * scale),
            Math.floor(rect.height * scale),
        )
        this.graphic.stroke();
    }

    // 获取可视区域矩形
    // mapRoot 的大小通过 widget 设置边距都为 0,所以 mapRoot 刚好就是屏幕的大小
    getVisibleRect() {
        return new cc.Rect(-this.node.x, -this.node.y, this.node.parent.width, this.node.parent.height)
    }

    // 判断坐标位置的图块是否可见
    isTileVisible(x, y) {
        let tile = this.config.tile
        let tileW = tile[0]
        let tileH = tile[1];
        let tileBound = new cc.Rect(x * tileW, y * tileH, tileW, tileH);

        let visibleBound = this.getVisibleRect();
        return tileBound.intersects(visibleBound);
    }

    loadTexure() {
        let cnt = this.config.cnt
        let pos
        for (let x = 0; x < cnt[0]; x++) {
            for (let y = 0; y < cnt[1]; y++) {
                if (this.isTileVisible(x, y)) {
                    pos = [x, y]
                    this.loadingQueue.push(pos)
                }
            }
        }
        this.loadTile()
    }

    loadTile() {
        if (this.loadingQueue.length === 0) {
            this.onLoadFinish()
            return
        }

        let pos = this.loadingQueue.pop()
        let tileName = "tile_" + pos[0] + "_" + pos[1]
        let url = "map/" + this.mapId + "/" + tileName
        cc.resources.load(url, cc.SpriteFrame, (error: Error, spf: cc.SpriteFrame) => {
            if (error) {
                console.error(error.message || error)
            }
            let node = new cc.Node()
            node.setAnchorPoint(0, 0)
            node.parent = this.node
            node.name = tileName
            node.zIndex = 1
            let tile = this.config.tile
            node.x = pos[0] * tile[0]
            node.y = pos[1] * tile[1]
            let sp = node.addComponent(cc.Sprite)
            sp.spriteFrame = spf

            this.loadTile()
        })
    }

    onLoadFinish() {
        console.log("loading time:", new Date().getTime() - this.stm, "ms")
        if (this.loadFinishCb) {
            this.loadFinishCb()
        }
    }

    median(min, value, max) {
        return Math.min(Math.max(min, value), max);
    }

    onActorMove(x, y){
        let viewSize = cc.winSize;
        this.node.x = this.median(viewSize.width - this.node.width, viewSize.width / 2 - x, 0)
        this.node.y = this.median(viewSize.height - this.node.height, viewSize.height / 2 - y, 0)
        this.updateMiniVisible()
        this.loadTexure()
    }
}

最后,我是寒风,欢迎加入Q群(830756115)讨论。
qq

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值