【乐府】如何巧用设计图提高UI的还原度

【本文参与征文活动】

前言

​ UI界面是游戏的重要组成部分,也是玩家在游戏内接触最多的东西。一个UI界面的感官体验直接决定着这个游戏的生命。那么一个好的UI界面,不仅要美术同学设计的好,还要前端/UI(一些公司会有专门拼UI的)还原的好。如果一个界面设计属于S,而放入游戏却只有C。那岂不是相当可惜。所以我这篇文章将介绍,如何在Creator中更方便快捷的使用设计图,提高UI还原度。

我有调研过一些公司如何去提高UI还原度问题。

大概分为一下几种:

第一类:就是美术标好坐标边距,然后前端根据设计图的标注拼界面;

​ 缺点:美术费事费力,前端亦然,切还原度还不高

第二类:把设计图图放到界面后,对着图放控件,然后拼完移除;

​ 缺点:美术还原度高,但是用完需要手动移除,有忘记移除的风险。

第三类:使用工具生成对应的界面,然后做微调。

​ 缺点:对美术要求高,需要按照某个标准去制作,导出来的Prefab参差不齐。

总结以上问题,我采用第二种方案,然后在此基础上开发特定脚本来避免忘记移除的问题。(功能的灵感来自FairyGUI)

一、功能设计

1、需求分析

​ 首先我们需要的基本功能大概就是,可以把设计图放到界面里,无额外的节点产生,并且不能影响界面中的操作。所以总结需求结合后期使用,总结出来设计图应该具备以下特征:

  • 不影响界面控件的点击

  • 设计图的节点在层级树里不能显示,但仍然可以设置位置和透明度等

  • 可设置显示的层级,在界面最上端还是在最低端

  • 不会被Creator计算引用,并打包到发布的项目中

  • 仅在当前界面显示,复制或嵌套等不能显示

    功能解释:

    不影响界面控件的点击

    ​ 我们添加的设计图仅用来对位的,如果能够选中会影响界面布局等。

    设计图的节点在层级树里不能显示,但仍然可以设置位置和透明度等

    ​ 主要是设计图不是界面的一部分,只是一个辅助工具,所以不应该显示再层级树里,但是为了方便对位,又需要设置透明度和位置偏移等。

    可设置显示的层级,在界面最上端还是在最低端

    ​ 为了可以方便用户选中设计图在顶端还是底端,防止设计图影响界面效果显示。

    不会被Creator计算引用,并打包到发布的项目中

    ​ 这个是最重要的部分,也是不好实现的部分。如果界面中直接引用了某个图片,在发布项目的时候,Creator会自动计算依赖关系并打包到项目中,而设计图只是用来拼界面的。不需要,也不能被打包到项目中。

    仅在当前界面显示,复制或嵌套等不能显示

    ​ 如果当前prefab被其他界面引用,或者是嵌套使用,当前界面的预览图不能显示。因为当前界面的设计图往往是当前界面的样式,所以仅需要在当前界面显示。在其他界面显示反而会影响其他界面的开发。

2、实现方案

首先我们先把设计图能显示到界面中:

const {ccclass, property, executeInEditMode} = cc._decorator;

@ccclass
@executeInEditMode	// 在编辑器中执行
export default class DesignView extends cc.Component {

    @property
    private _spriteFrame: cc.SpriteFrame = null;

    @property({type: cc.SpriteFrame, visible: true, displayName: "设计图"})
    public set spriteFrame(frame: cc.SpriteFrame) {
        this._updateDesignView(frame);
    }

    public get spriteFrame(): cc.SpriteFrame {
        return this._spriteFrame;
    }

    private _sprite: cc.Sprite = null;

    private _updateDesignView(frame: cc.SpriteFrame, init: boolean = false) {
        if (this._spriteFrame === frame && !init) {
            return;
        }
        if (!this._sprite) {
            let viewNode = new cc.Node("DesignView");
            this._sprite = viewNode.addComponent(cc.Sprite);
            viewNode.parent = this.node;
        }
        this._sprite.spriteFrame = frame;
        this._spriteFrame = frame;
    }

    public onLoad() {
        // 激活当前界面的时候显示预览图
        this._updateDesignView(this._spriteFrame, true);
    }
    
	// 当前脚本激活时,显示预览图
    public onEnable() {
        if (this._sprite) {
            this._sprite.enabled = true;
        }
    }

    // 当前脚本取消激活时,隐藏预览图
    public onDisable() {
        if (this._sprite) {
            this._sprite.enabled = false;
        }
    }

    public onDestroy() {
        // 当前脚本被手动移除时,删除预览图
        if (this._sprite && !(this._sprite.node["_objFlags"] & cc.Object["Flags"].Destroying)) {
            this._sprite.node.destroy();
        }
    }
}

简单的功能实现了,把设计图拖到后边的设计图属性上,界面出来了,但是这只是设计图,我们不想让他在左上角的层级树显示,因为并且也不能都点击到,这样才能方便我们布局界面。如果有看我写过嵌套的小伙伴可能就比较清楚了,这里我就不做过多的赘述了:

viewNode["_objFlags"] |= (cc.Object["Flags"].DontSave | cc.Object["Flags"].LockedInEditor | cc.Object["Flags"].HideInHierarchy);

完成上面的步骤之后,我们再来看看界面中①②③④,是不是功能已经实现了,把预览图拖到对应的位置,界面中显示,并且不会多出其他任何信息。

到这我们已经完成50%了,但是还有两个比较重要的问题还没显示,其一,就是发布项目的时候,设计图不能被打包到项目中,这才是我们要实现的精髓部分。这个功能实现不了,整个功能就是白费。

我通过各种查阅古籍和翻阅官方文档,终于知道了一个救命稻草editorOnly,你们现在是不是也和我一样揣着无比喜悦的心情。最困难的问题终于要得到解决了。

@property({editorOnly: true})
private _spriteFrame: cc.SpriteFrame = null

修改好了以后,发布项目测试。

What?还在,说好的 在导出项目前剔除该属性 呢?好吧!没办法了只能另辟蹊径了。

这时候只能想看看本地是如何识别纹理的。打开对应的prefab,找到对应的位置格式就是"__uuid__":"xxx",所以这里我大胆的猜测官方就是识别的__uuid__,而和后面的值一点关系都没有,所以如果我保存一个其他类型值他是不是就不知道是否引用了呢?

const {ccclass, property, executeInEditMode} = cc._decorator;

@ccclass
@executeInEditMode
export default class DesignView extends cc.Component {

    // 删除@property
    private _spriteFrame: cc.SpriteFrame = null;

    // 添加保存纹理的属性
    @property
    private _designUrl: string = "";
....
	private _updateDesignView(frame: cc.SpriteFrame, init: boolean = false) {
        if (this._spriteFrame === frame && !init) {
            return;
        }
        this._designUrl = frame ? frame["_uuid"] : "";
        if (!this._sprite) {
            let viewNode = new cc.Node("DesignView");
            this._sprite = viewNode.addComponent(cc.Sprite);
            viewNode.parent = this.node;
            viewNode["_objFlags"] |= (cc.Object["Flags"].DontSave | cc.Object["Flags"].LockedInEditor | cc.Object["Flags"].HideInHierarchy);
        }
        this._sprite.spriteFrame = frame;
        this._spriteFrame = frame;
    }
...

现在再执行导出操作,确实项目中不会有设计图资源了。但是又出现了另一个问题,那就是我们重新打开当前界面的时候,设计图也不会被创建了。因为_spriteFrame已经不是编辑器属性了,所以他不会被默认创建了,我们现在有的就是纹理的uuid,所以我们现在能做的就是手动创建纹理出来。

public onLoad() {
        if (this._designUrl) {
            if (cc.assetManager && cc.assetManager.loadAny) {
                cc.assetManager.loadAny({type: "uuid", uuid: this._designUrl}, null, (err, spriteFrame: cc.SpriteFrame)=> {
                    if (!err) {
                        this._updateDesignView(spriteFrame);
                    }
                })
            }
            else {
                // 兼容2.4.0之前版本
                cc.loader.load({type: "uuid", uuid: this._designUrl}, null, (err, spriteFrame: cc.SpriteFrame)=> {
                    if (!err) {
                        this._updateDesignView(spriteFrame);
                    }
                })
            }
        }
    }

修改完以后,测试成功,喜大普奔。这里我就贴效果图了。到这里我们的主要功能就实现完了。下面就是如何实现被嵌套使用的时候不显示设计图问题了。(这里我就不过多解释原因了。)

如何实现设计图只在当前界面生效呢?

​ 方案一:判断当前界面是不是在Scene层显示(我们所打开的所有界面,场景或者预制体的parent都是一个scene)。

​ 方案二:找到一个唯一性标识的字段。

对于场景(Scene)来说,不会出现场景嵌套场景的情况,所以这里不做讨论。

通过代码和prefab文件的研究,发现有个fileId属性,当prefab的parent不是scene时,这个id会变成,prefab的parent的fileId。这里我们刚好可以利用这一点,在第一次挂在DesignView脚本时,保存fileId来记录默认的界面,一旦id发生改变,设计图就不显示。

private _isCurPrefab(): boolean {
    let prefab = this.node["_prefab"];
    return prefab && prefab.root && (!this._prefabFileId || this._prefabFileId === prefab.fileId);
}

到这里,我们的核心功能已经实现了。但是因为设计图的节点被我们隐藏了,所以我们要添加一些接口可以用来这是透明度、层级和位置偏移等。下面是实现的最终版代码。


const {ccclass, property, executeInEditMode} = cc._decorator;

@ccclass
@executeInEditMode
export default class DesignView extends cc.Component {

    private _sprite: cc.Sprite = null;
    private _spriteFrame: cc.SpriteFrame = null;

    @property
    private _prefabFileId: string = "";

    @property
    private _opacity: number = 70;

    @property
    private _offsetX: number = 0;

    @property
    private _offsetY: number = 0;

    @property
    private _showTop: boolean = true;

    @property
    private _designUrl: string = "";

    @property({type: cc.SpriteFrame, displayName: "设计图"})
    public set spriteFrame(frame: cc.SpriteFrame) {
        this._updateDesignView(frame);
    }

    public get spriteFrame(): cc.SpriteFrame {
        return this._spriteFrame;
    }

    @property({type: cc.Boolean, displayName: "置顶"})
    public set showTop(top: boolean) {
        this._showTop = top;
        this._updateOrder();
    }

    public get showTop(): boolean {
        return this._showTop;
    }

    @property({type: cc.Integer, displayName: "透明度", slide: true, min : 0, max: 255})
    public set opacity(value: number) {
        this._opacity = value;
        this._updateOpatity();
    }

    public get opacity(): number {
        return this._opacity;
    }

    @property({type: cc.Integer, displayName: "X偏移"})
    public set offsetX(value: number) {
        this._offsetX = value;
        this._updateOffset();
    }

    public get offsetX(): number {
        return this._offsetX;
    }

    @property({type: cc.Integer, displayName: "Y偏移"})
    public set offsetY(value: number) {
        this._offsetY = value;
        this._updateOffset();
    }

    public get offsetY(): number {
        return this._offsetY;
    }

    private _updateDesignView(frame: cc.SpriteFrame) {
        if (this._spriteFrame === frame) {
            return;
        }
        this._designUrl = frame ? frame["_uuid"] : "";
        if (!this._sprite) {
            let viewNode = new cc.Node("DesignView");
            this._sprite = viewNode.addComponent(cc.Sprite);
            viewNode.parent = this.node;
            viewNode["_objFlags"] |= (cc.Object["Flags"].DontSave | cc.Object["Flags"].LockedInEditor | cc.Object["Flags"].HideInHierarchy);

            this._updateOffset();
            this._updateOrder();
            this._updateOpatity();
        }
        this._sprite.spriteFrame = frame;
        this._spriteFrame = frame;
    }

    private _updateOffset() {
        if (!this._sprite) {
            return;
        }
        let node = this._sprite.node;
        node.x = this._offsetX;
        node.y = this._offsetY;
    }

    private _updateOrder() {
        if (!this._sprite) {
            return;
        }
        this._sprite.node.zIndex = this._showTop ? 999 : -1;
    }

    private _updateOpatity() {
        if (!this._sprite) {
            return;
        }
        this._sprite.node.opacity = this._opacity;
    }

    public onLoad() {
        if (!CC_EDITOR) {
            return;
        }
        if (!this._prefabFileId && this.node["_prefab"]) {
            this._prefabFileId = this.node["_prefab"].fileId;
        }
        if (this._isCurPrefab()) {
            if (this._designUrl) {
                if (cc.assetManager && cc.assetManager.loadAny) {
                    cc.assetManager.loadAny({type: "uuid", uuid: this._designUrl}, null, (err, spriteFrame: cc.SpriteFrame)=> {
                        if (!err) {
                            this._updateDesignView(spriteFrame);
                        }
                    })
                }
                else {
                    // 兼容2.4.0之前版本
                    cc.loader.load({type: "uuid", uuid: this._designUrl}, null, (err, spriteFrame: cc.SpriteFrame)=> {
                        if (!err) {
                            this._updateDesignView(spriteFrame);
                        }
                    })
                }
            }
            if (this._sprite) {
                this._updateOffset();
                this._updateOrder();
                this._updateOpatity();
            }
        }
        else {
            this.destroy();
        }
    }

    private _isCurPrefab(): boolean {
        let prefab = this.node["_prefab"];
        return prefab && prefab.root && (!this._prefabFileId || this._prefabFileId === prefab.fileId);
    }

    public onEnable() {
        if (this._sprite) {
            this._sprite.enabled = true;
        }
    }

    public onDisable() {
        if (this._sprite) {
            this._sprite.enabled = false;
        }
    }

    public onDestroy() {
        if (this._sprite && !(this._sprite.node["_objFlags"] & cc.Object["Flags"].Destroying)) {
            this._sprite.node.destroy();
        }
    }
}

最终界面显示效果如下:

现在我们在开发过程中,只要把design-view挂载到跟节点,然后把设计图拖到设计图属性位置,就可以愉快的开发了,也不用担心设计图资源忘记移除,被打包到项目中了 (除非你把设计图放到resources目录下,你这样做了,我也帮不了你)

14赞

把设计图直接放进去,这么机灵的用法我竟然从来没想过,我一直是用MarkMan自己量距离来摆的:joy:,学到了。

关于实现我有另外一种思路,通过插件直接修改编辑器的DOM tree,把设计稿塞到下面,这样对工程就0侵入了。

1赞

你这个想法也挺不错,之前没接触过。有空可以研究一下

你们对 Creator 真是研究得太深入了……
editorOnly 应该是能剔除资源的,可能是 bug 哈哈哈,我们下个版本完善下

2赞

其实还好,不影响正常开发流程。我们也就是做了一些优化工作流程的事情,方便开发而已。

大佬太强了叭

挺好用的…

我的方式就是直接设计图拖到场景,然后构建的时候看下警告,因为设计图都是大图,合图的时候合不进去,就会有警告在控制台

拼的时候放背景进去,拼完删掉 = =!
就是麻烦点。

麻烦才是研究技术的动力

除了设计图,一些占位的精灵和文本也是只在编辑时有用的,发布后不希望包含在预设里面,不然会影响加载和实例化速度
design-view脚本在构建后会自动删除吗

design-view不会自动剔除,这一块暂时还没找到好方法,但是有办法把design-view限制到尽可能小。
你说的文字和精灵的占位问题,我们确实有实现,其实方法就在设计图实现里。

1赞

很厉害,这样拼完界面就不用删除背景图了

前公司 美术就是这样跟我说的:wink:不过放的位置差不多就行了 偏差不会太多

来晚了,我应该早点看到这篇文章,我就不会被UI折磨了。

厉害了啊,学习一下

老哥 什么时候修复, 2.4.3版本还是会吧资源打进包里

creator + js 的项目 将脚本拖进去,闪退