查看: 23097|回复: 37

[原创发布] 从0开始自制UI

[复制链接]
梦石
0
星屑
755
在线时间
90 小时
注册时间
2017-11-6
回帖
10
发表于 2021-2-24 00:05:18 | 显示全部楼层 |阅读模式

加入我们,或者,欢迎回来。

您需要 登录 才可以下载或查看,没有账号?注册会员

×
本帖最后由 lohengriny 于 2021-2-26 22:35 编辑

原生的UI实在是太丑了,而且对触屏、滑动等的支持也不太好,我们需要重新构建一个。
先放一个目前的效果:(仿时空猫)
https://b23.tv/8tGk6Z

[fold=第一部分:基类 Clickable]功能构想
我期望的UI主要有窗口和按钮构成,窗口至少要有普通窗口、可滚动窗口、可滚动列表窗口(列表item可交互)等几个类型。
对于滚动窗口,还需要有滚动条,以及滚动惯性(快速滚动后松开鼠标,减速滑动一段距离直到停止),滚动极限反馈(超过滚动极限时会“弹”回来),列表Item长按功能(比如显示详情)

原型
以上一些功能窗口,和按钮都有一个共同点,就是可以点击交互,点击还要包括长按和非长按,以及按住滚动等,为此可建一个类叫 Clickable, 为了让它有 transform,可以作为 child 添加给别人我们要继承 PIXI.Container,先写一个简单的框架:

[pre lang="javascript"]class Clickable extends PIXI.Container {
    constructor(x = 0, y = 0, w = 0, h = 0) {
        super();
        this.x = x;
        this.y = y;

        //宽和高必不可少
        this._width = w;
        this._height = h;

        //是否处于激活(可交互)状态
        this._active = true;
    }

    get width() {return this._width;}
    get w() {return this._width;} //宽的简称,为了以后可以偷懒
    get height() {return this._height;}
    get h() {return this._height;}

    get active() { return this._active;}
    Activate() { this._active = true;}
    Deactivate() { this._active = false;}
}[/pre]
在点击判断中,首先要判断点击是否在我们对象的区域内:

  1. /**  @returns {boolean} */
  2.     IsMouseOver() {
  3.         //通过PIXI的原生函数 DisplayObject.worldTransform.applyInverse(p: Point) 可以方便地把全局坐标变成本地坐标
  4.         const pos = this.worldTransform.applyInverse(new Point(TouchInput.x, TouchInput.y));
  5.         return pos.x >= 0 && pos.x <= this.w && pos.y >= 0 && pos.y <= this.h;
  6.     }
复制代码

接写来需要处理点击事件, 按照RM的风格我们把这个放到 update函数中。判定点击需要考虑以下情况:

鼠标按下 -> OnPress
鼠标按下后立即原地释放 -> OnRelease & OnClick
鼠标按下一段时间后原地释放 -> OnRelease & OnLongPressRelease (长按后即使原地释放一般也不认为是点击,考虑到长按一般是弹出悬浮窗口显示详情,这是释放应该是窗口消失,直接触发点击并不适宜。)
鼠标按下后移动到区域内另一位置释放 -> OnRelease & OnLongPressRelease
鼠标按下后移动到区域之外 -> OnLongPressRelease (鼠标处于点击状态移动到区域之外,如果有长按悬浮窗这时也应该消失,所以也处理为长按释放)
整理之后代码如下:

[pre lang="javascript"]_pressed = false; //记录“鼠标在区域内按下”
_longPressed = false; //记录“鼠标在区域内长按”
_pressPoint = new Point(0, 0); //记录鼠标按下的点坐标
_releasePoint = new Point(0, 0); //记录鼠标释放的点坐标

IsEnabled() {
    return this.visible && this._active;
}

update() {
    if (!this.IsEnabled()) return;

    for (const child of this.children.reverse.clone()) {
        if (child.update) {
            child.update();
        }
    }

    if (this._pressed) { //已经处于按下状态
        if (this.IsMouseOver()) {
            if (!this._longPressed && TouchInput.isLongPressed()) {
                this._longPressed = true;
                this.OnLongPress();
            }
            if(TouchInput.isReleased()) {
                if(this._longPressed) {
                    this.OnLongPressRelease(true);
                } else if(TouchInput.isClicked()){
                    this.OnClick();
                }
                this._pressed = false;
                this._longPressed = false;
                this.OnRelease();
            }
        } else { //鼠标在按下状态离开区域的情况
            if(this._longPressed) {
                this.OnLongPressRelease(false);
            }
            this._pressed = false;
            this._longPressed = false;
            this.OnRelease();
        }
    } else if (TouchInput.isTriggered()&&this.IsMouseOver()) { //开始按下
        this._pressed = true;
        this.OnPress();
    }
}
接下来是几个触发事件的函数,为了方便测试这里先用几个 console.log 好查看效果:

_handlers = {};

/**
* @param {Function} func
* @param {Function} handle_func
* @param args
*/
SetHandler(func, handle_func, ...args) {
    if(!this[func.name]) {
        throw new Error(`No method named ${func.name} found!`);
    } else {
        this._handlers[func.name] = handle_func.bind(this, ...args);
    }
}

CallHandler(name) {
    if(this._handlers[name]) {
        this._handlers[name]();
    }
}
s
OnPress() {
    this._pressPoint.set(TouchInput.x, TouchInput.y);
    console.log('press');
    this.CallHandler(this.OnPress.name);
}

OnRelease() {
    this._releasePoint.set(TouchInput.x, TouchInput.y);
    console.log('release');
    this.CallHandler(this.OnRelease.name);
}

OnClick() {
    console.log('click');
    this.CallHandler(this.OnClick.name);
}

OnLongPress() {
    console.log('long press start');
    this.CallHandler(this.OnLongPress.name);
}

/**
* @param {boolean} isClick
*/
OnLongPressRelease(isClick) {
    console.log(`long press release, this is${isClick?'':' not'} a click`);
    this.CallHandler(this.OnLongPressRelease.name);
}[/pre]
[/fold]

[fold=第二部分:基本窗口]
写好Clickable类之后便可以开始着手写Window了,不过在这之前还要做一些准备

Window 皮肤绘制
如同RM原生的Window皮肤,基本是一个正方形的格子,绘制的时候是把其上下左右边缘分别绘制,首先四个角按原样单独绘制,四条边进行长度缩放后绘制,中间填充双方向缩放绘制,这样可以将一个正方形在不失真的情况下绘成任意大小的举行(上面的缩放也可以用Tilemap是的排列,取决于中间区域是不是纯色);为了方便起见为Bitmap单独写这样一个绘制皮肤的方法,难度没有,就是比较麻烦。
[pre lang="javascript"]
/**
* @param {Bitmap} bitmap
* @param {number} dx
* @param {number} dy
* @param {number} dw
* @param {number} dh
* @param {number} lx
* @param {number} rx
* @param {number} uy
* @param {number} by
*/
Bitmap.prototype.DrawTexture = function(bitmap, dx, dy, dw, dh, lx, rx = lx, uy, by = uy) {
    let w = bitmap.width - lx - rx;
    let h = bitmap.height - uy - by;

    //left top
    this.blt(bitmap, 0, 0, lx, uy, dx, dy, lx, uy);
    //top
    this.blt(bitmap, lx, 0, w, uy, dx + lx, dy, dw - lx - rx, uy);
    //right top
    this.blt(bitmap, lx + w, 0, rx, uy, dx + dw - rx, dy, rx, uy);
    //left bottom
    this.blt(bitmap, 0, uy + h, lx, by, dx, dy + dh - by, lx, by);
    //bottom
    this.blt(bitmap, lx, uy + h, w, by, dx + lx, dy + dh - by, dw - lx -rx, by);
    //right bottom
    this.blt(bitmap, lx + w, uy + h, rx, by, dx + dw - rx, dy + dh - by, rx, by);
    //left
    this.blt(bitmap, 0, uy, lx, h, dx, dy + uy, lx, dh - uy - by);
    //right
    this.blt(bitmap, lx + w, uy, rx, h, dx + dw - rx, dy + uy, rx, dh - uy - by);
    //center
    this.blt(bitmap, lx, uy, w, h, dx + lx, dy + uy, dw - lx - rx, dh - uy - by);
};
另外一个准备,就是写一个Paddings类,方便一点,不然每次都要写四个看起来比较乱,比较简单就不具体说明了:

class Paddings {
    /**
     * @param {number} left
     * @param {number} top
     * @param {number} right
     * @param {number} bottom
     */
    constructor(left = 0, top = left, right = left, bottom = top) {
        this._left = left;
        this._top = top;
        this._right = right;
        this._bottom = bottom;
    }
    _left = 0;
    _top = 0;
    _right = 0;
    _bottom = 0;
    /** @returns number */
    get left() { return this._left; }
    /** @returns number */
    get top() { return this._top; }
    /** @returns number */
    get right() { return this._top; }
    /** @returns number */
    get bottom() { return this._bottom; }
}
[/pre]
Window
准备好之后可以开始写 Window 了,首先还是先思考一下需求。 Window 由标题、背景、内容构成。(标题我后来放弃,没什么用,就不提了,不过加进去也简单)。背景就是窗口的背景板,背景上画皮肤;而内容是一个透明的空白图片,是写文字图片等内容的地方,覆盖在背景上面,在二者都用 Sprite 作为载体:
[pre lang="javascript"]
class CustomWindow extends Clickable { //继承Clickable
    _title = '';
    _titleHeight = 0;
    /** @type Bitmap */
    _titleTexture;
    /** @type Bitmap */
    _backTexture;
    _active = false;
    /**
     * @param {number} x
     * @param {number} y
     * @param {number} w
     * @param {number} h
     * @param {string} title - 标题:废弃了
     * @param {string} titlebg - 标题皮肤(背景):废弃了
     * @param {number} th - 标题高度:废弃了
     * @param {string} bg - 皮肤(背景)
     */
    constructor(x, y, w, h, title, th, titlebg = 'wd_title_cmn', bg = 'wd_back_cmn') { //以默认背景图片作为默认参数
        super(x, y, w, h);
        this._title = title;
        this._titleHeight = th;
        this._contentPaddings = new Paddings(12, 12, 12, 12); //内容paddings,即内容与内容 sprite 的边距
        this.Create();
        this.titleTexture = ImageManager.ImageManager.loadBitmap('img/ui', titlebg);
        this.backTexture = ImageManager.loadBitmap('img/ui', bg);
        this.Move(x, y, w, h);
    }

    get backTexture() { return this._backTexture;}
    set backTexture(val) {
        if (this._backTexture !== val) {
            this._backTexture = val;
            this._backTexture.addLoadListener(this.OnBackTextureLoaded.bind(this)); //因为读取图片需要时间,使用回调函数
        }
    }

    get titleTexture() { return this._titleTexture;}
    set titleTexture(val) {
        if (this._titleTexture !== val) {
            this._titleTexture = val;
            this._titleTexture.addLoadListener(this.OnTitleTextureLoaded.bind(this));
        }
    }

    /** @returns {Bitmap} */
    get content() { return this._contentSprite.bitmap; }

    /**
     * content paddings
     * @returns {Paddings}
     */
    get paddings() { return this._contentPaddings; }

    /** @returns number */
    get contentAreaHeight() {return this.height - this._titleHeight;}
    /** @returns number */
    get contentHeight() { return this.contentAreaHeight - this.paddings.top - this.paddings.bottom; }
    /** @returns number */
    get contentWidth() { return this.width - this.paddings.left - this.paddings.right; }


    /**
     * 移动窗口,如果宽高发生变化则需要重新绘制
     * @param {number} x - x coordinate
     * @param {number} y - y coordinate
     * @param {number} width
     * @param {number} height
     */
    Move(x = 0, y = 0, width = 0, height = 0) {
        this.x = x;
        this.y = y;
        if (this._width !== width || this._height !== height) {
            this._width = width;
            this._height = height;
            this.RefreshAllParts();
        }
    }

    Close() {
        this.Deactivate();
        this.visible = false;
    }

    Open() {
        this.visible = true;
    }

    // 创建Sprite
    Create() {
        this.CreateBackSprite();
        this.CreateContentSprite();
        this.CreateTitleSprite();
    }

    /** @type Sprite */
    _titleSprite;
    /** @type Sprite */
    _backSprite;
    /** @type Sprite */
    _contentSprite;
    CreateTitleSprite() {
        this._titleSprite = new Sprite();
        this.addChild(this._titleSprite);
    }

    CreateBackSprite() {
        this._backSprite = new Sprite();
        this.addChild(this._backSprite);
        this._backSprite.move(0, this._titleHeight);
    }

    CreateContentSprite() {
        this._contentSprite = new Sprite(new Bitmap(this.contentWidth, this.contentHeight));
        this.addChild(this._contentSprite);
        this._contentSprite.move(this.paddings.left, this._titleHeight + this.paddings.top);
    }

    //刷新窗口内容
    OnTitleTextureLoaded() {
        if (this._titleHeight > 0) {
            let b = new Bitmap(this.width, this._titleHeight + 12);
            b.DrawTexture(this.titleTexture, 0, 0, this.width, this._titleHeight + 4, this.TitleBorders.left, this.TitleBorders.right, this.TitleBorders.top, this.TitleBorders.bottom);
            b.drawText(this._title, 0, 0, this.width, this._titleHeight + 4, 'center');
            this._titleSprite.bitmap = b;
        }
    }

    OnBackTextureLoaded() {
        let b = new Bitmap(this.width, this.height - this._titleHeight);
        b.DrawTexture(this.backTexture, 0, 0, this.width, this.height - this._titleHeight, this.BackBorders.left, this.BackBorders.right, this.BackBorders.top, this.BackBorders.bottom);
        this._backSprite.bitmap = b;
    }

    RefreshAllParts() {
        this.RefreshTexturePosition();
        this.RefreshContent();
    }

    RefreshTexturePosition() {
        this.OnTitleTextureLoaded();
        this.OnBackTextureLoaded();
    }

    RefreshContent() {
        this._contentSprite.bitmap = new Bitmap(this.contentWidth, this.contentHeight);
        this._contentSprite.move(this.paddings.left, this._titleHeight + this.paddings.top);
    }
}[/pre]
此外还窗口还要有一些绘制函数,用来更方便地绘制文字、图片,这个参考原生的 Window 抄过来就可以了,就不再详细说明。
[/fold]

[fold=第三部分:基本滚动窗口]
论坛太难编辑,我写到github博客里,然后这里放链接吧:https://araragihoozuki.github.io/2021/02/26/rmmz-ui-window-3/
[/fold]
下一部分准备写滚动条

评分

参与人数 12赞 +12 收起 理由
say8ai + 1 精品文章
vgongong2022 + 1 我很赞同
bininb + 1
trinityhlm + 1 塞糖
海风HF + 1 塞糖
SugerDream + 1 精品文章
暗黑哀悼 + 1 精品文章
泠泣linki + 1 塞糖
comqocit + 1 精品文章
pkeasygod + 1 精品文章

查看全部评分

梦石
0
星屑
2429
在线时间
363 小时
注册时间
2021-2-8
回帖
137
发表于 2021-2-24 09:30:40 | 显示全部楼层
哇,大佬,支持!
回复

使用道具 举报

梦石
0
星屑
150
在线时间
18 小时
注册时间
2021-1-18
回帖
5
发表于 2021-2-24 12:24:32 | 显示全部楼层
演示视频666~ 好看
回复

使用道具 举报

梦石
0
星屑
32
在线时间
4 小时
注册时间
2021-2-14
回帖
4
发表于 2021-2-24 12:52:27 | 显示全部楼层
大佬好强
回复

使用道具 举报

梦石
0
星屑
32
在线时间
4 小时
注册时间
2021-2-14
回帖
4
发表于 2021-2-24 12:54:43 | 显示全部楼层
大佬请问敌人根据动画呼吸摆动的插件有链接么?
回复

使用道具 举报

梦石
0
星屑
662
在线时间
56 小时
注册时间
2021-1-23
回帖
35
发表于 2021-2-24 14:03:30 | 显示全部楼层
每天学习一下
回复

使用道具 举报

梦石
0
星屑
755
在线时间
90 小时
注册时间
2017-11-6
回帖
10
 楼主| 发表于 2021-2-24 14:38:52 来自手机 | 显示全部楼层
lijingwei 发表于 2021-2-24 12:54
大佬请问敌人根据动画呼吸摆动的插件有链接么?

我是用的spine谷骨骼动画,虽然素材是从别的手游解的。。。。
回复

使用道具 举报

梦石
0
星屑
1437
在线时间
444 小时
注册时间
2009-11-24
回帖
49
发表于 2021-2-24 23:27:02 | 显示全部楼层
好厉害,好强
回复

使用道具 举报

梦石
0
星屑
4584
在线时间
369 小时
注册时间
2020-1-29
回帖
211
发表于 2021-2-25 06:28:03 | 显示全部楼层
支持支持!
回复

使用道具 举报

梦石
0
星屑
662
在线时间
56 小时
注册时间
2021-1-23
回帖
35
发表于 2021-2-25 09:37:35 | 显示全部楼层
还有后续吗?
回复 1 0

使用道具 举报

您需要登录后才可以回帖 登录 | 注册会员

本版积分规则

Powered by Discuz! X5.0 © 2001-2026 Discuz! Team.

在本版发帖返回顶部