HumanBreak/docs/guide/ui.md
2025-03-13 19:38:39 +08:00

30 KiB
Raw Blame History

lang
zh-CN

UI 编写

本文将介绍如何在 2.B 样板中编写 UI以及如何优化 UI 性能。

创建 UI 文件

首先,我们打开 packages-user/client-modules/render 文件夹,这里是目前样板的 UI 目录(之后可能会修改),我们可以看到 components legacy ui 三个文件夹,其中 component 是组件文件夹,也就是所有 UI 都可能用到的组件,例如滚动条、分页、图标等,这些东西不会单独组成一个 UI但是可以方便 UI 开发。legacy 文件夹是将要删除或重构的内容,不建议使用里面的内容。ui 就是 UI 文件夹,这里面存放了所有的 UI我们在这里创建一个文件 myUI.tsx

编写 UI 模板

下面,我们需要编写 UI 模板,以怪物手册为例,模板如下,直接复制粘贴即可:

import { defineComponent } from 'vue';
import { GameUI, UIComponentProps } from '@motajs/system-ui';
import { SetupComponentOptions } from '../components';

export interface MyBookProps extends UIComponentProps {}

const myBookProps = {
    props: ['controller', 'instance']
} satisfies SetupComponentOptions<MyBookProps>;

export const MyBook = defineComponent<MyBookProps>(props => {
    return () => <container></container>;
}, myBookProps);

export const MyBookUI = new GameUI('my-book', MyBook);

然后打开 index.ts,增加如下代码:

export * from './myUI';

添加一些内容

新的 UI 使用 tsx 编写,即 TypeScript JSX,可以直接在 ts 文件中编写 XML非常适合编写 UI。例如我们想要把 UI 的位置设为水平竖直居中,位置在 240, 240长宽为 480, 480并显示一个文字可以这么写

// ... 其他内容
// loc 参数表示这个元素的位置,六个数分别表示:
// 横纵坐标长宽水平竖直锚点0.5 表示居中1 表示靠右或靠下对齐,可以填不在 0-1 范围的数
// 每两项组成一组,这两项要么都填,要么都不填,例如长宽可以都不填,横纵坐标可以都不填
// 不填时会使用默认值,或是组件内部计算出的值
return () => (
    <container loc={[240, 240, 480, 480, 0.5, 0.5]}>
        {/* 文字元素会自动计算长宽,因此不能手动指定 */}
        <text text="这是一段文字" loc={[240, 240, void 0, void 0, 0.5, 0.5]} />
    </container>
);

显示 UI

我们编写完 UI 之后,这个 UI 并不会自己显示,需要手动打开。我们找到 ui/main.tsx,在 MainScene 这个根组件中添加一句话:

// 在这添加引入
import { MyBookUI } from './ui';
// ... 其他内容
const MainScene = defineComponent(() => {
    // ... 其他内容
    // 在这添加一句话,打开 UI第二个参数为传入 UI 的参数,后面会有讲解
    // 纵深设为 100 以保证可以显示出来,纵深越大,元素越靠上,会覆盖纵深低的元素
    mainUIController.open(MyBookUI, { zIndex: 100 });
    return () => (
        // ... 其他内容
    );
});

这样的话,我们就会在页面上显示一个新的 UI 了!不过这个 UI 会是常亮的 UI没办法关闭我们需要更精细的控制。我们可以在内部使用 props.controller 来获取到 UI 控制器实例,使用 props.instance 获取到当前 UI 实例,从而控制当前 UI 的状态:

export const MyBook = defineComponent<MyBookProps>(props => {
    // 例如,我们可以让它在打开 10 秒钟后关闭:
    setTimeout(() => props.controller.close(props.instance), 10000);
    return () => (
        // ... UI 内容
    );
}, myBookProps);

除此之外,我们还可以在任意渲染端模块中引入 ui/controller 来获取到根组件的 UI 控制器,注意跨文件夹引入时需要引入 @user/client-modules。例如,我们可以在其他文件中控制这个 UI 的开启与关闭:

import { mainUIController, MyBookUI } from './ui';
import { IUIInstance } from '@motajs/system-ui';

let myBookInstance: IUIInstance;
export function openMyBook() {
    // 使用一个变量来记录打开的 UI 实例
    myBookInstance = mainUIController.open(MyBookUI, {});
}

export function closeMyBook() {
    // 传入 UI 实例,将会关闭此 UI 及其之后的 UI
    mainUIController.close(myBookInstance);
}

也可以使用 Mota.require 引入:

const { mainUIController } = Mota.require('@user/client-modules');

也可以通过 UIController 的接口获取其实例:

import { UIController } from '@motajs/system-ui';

const mainUIController = UIController.getController('main-ui');

更多的 UI 控制功能可以参考后续文档以及相关的 UI 系统指南API 文档

添加更多内容

既然我们要编写一个简易怪物手册,那么仅靠上面这些内容当然不够,我们需要更多的元素和组件才行,下面我们来介绍一些常用的元素及组件。

图标

既然是怪物手册,那么图标必然不能少,图标是 <icon> 元素,需要传入 icon 参数,例如:

return () => (
    <container>
        {/* 显示绿史莱姆图标,位置在 (32, 32),循环播放动画 */}
        <icon icon="greenSlime" loc={[32, 32]} animate />
    </container>
);

字体

我们很多时候也会想要自定义字体,可以通过 Font 类来实现这个功能:

import { Font, FontWeight } from '@motajs/render';

// 创建一个字体,包含五个参数,第一个是字体名称,第二个是字体大小,第三个是字体大小的单位,一般是 'px'
// 第四个是字体粗细,默认是 400可以填 FontWeight.BoldFontWeight.Light 或是数字,范围在 1-1000 之间
// 第五个是是否斜体。每个参数都是可选,不填则使用默认字体的样式。
const font = new Font('myFont', 24, 'px', FontWeight.Bold, false);
// 可以将这个字体设置为默认字体,之后的所有没有指定的都会使用此字体
Font.setDefaults(font);
// 如果需要使用默认字体,有两种写法
const font = new Font();
const font = Font.defaults();

return () => (
    <container>
        <icon icon="greenSlime" loc={[32, 32]} animate />
        <text
            text="绿史莱姆"
            // 使用上面定义的字体
            font={font}
            // 靠左对齐上下居中对齐
            loc={[64, 48, void 0, void 0, 0, 0.5]}
        />
    </container>
);

更多的字体使用方法可以参考 API 文档

圆角矩形

我们可以为怪物手册的一栏添加圆角矩形,写法如下:

return () => (
    <container>
        <g-rectr
            // 圆角矩形的位置
            loc={[16, 16, 480 - 32, 480 - 32]}
            // 圆角矩形为仅描边
            stroke
            // 圆角半径可以设置四个具体参考圆角矩形的文档
            circle={[8]}
            // 描边样式这里设为了金色
            strokeStyle="gold"
        />
    </container>
);

线段

我们也可以添加线段,作为怪物列表之间的分割线:

return () => (
    <container>
        <g-line
            // 线段的起始位置和终止位置不需要指定 loc 属性
            line={[16, 80, 480 - 16, 80]}
            // 线的端点为圆形
            lineCap="round"
            // 线宽为 1
            lineWidth={1}
            // 虚线样式5 个像素为实5 个像素为虚
            lineDash={[5, 5]}
        />
    </container>
);

winskin 背景

我们可以为手册添加一个 winskin 背景,可以使用 Background 组件:

// 从 components 文件夹中引入这个组件
import { Background } from '../components';

return () => (
    <container loc={[240, 240, 480, 480, 0.5, 0.5]}>
        <Background
            // 位置是相对于父元素的因此从 (0, 0) 开始
            loc={[0, 0, 480, 480]}
            // 设置 winskin 的图片名称
            winskin="winskin.png"
        />
    </container>
);

滚动条

怪物多的话一页肯定显示不完,因此我们可以添加一个滚动条 Scroll 组件,用法如下:

// 从 components 文件夹中引入这个组件
import { Scroll } from '../components';

return () => (
    // 使用滚动条组件替换 container 元素
    <Scroll loc={[240, 240, 480, 480, 0.5, 0.5]}> // [!code ++]
        <Background
            // 位置是相对于父元素的因此从 (0, 0) 开始
            loc={[0, 0, 480, 480]}
            // 设置 winskin 的图片名称
            winskin="winskin.png"
        />
        {/* 其他内容 */}
    </Srcoll> // [!code ++]
);

在使用滚动条时,建议使用平铺式布局,将每个独立的内容平铺显示,而不是整体包裹为一个 container,这有助于提高性能表现。

循环

编写怪物手册的话,我们就必须用到循环,因为我们需要遍历当前怪物列表,然后每个怪物生成一个 container,在这个 container 里面显示内容。tsx 为我们提供了嵌入表达式的功能,因此我们可以通过 map 方法来遍历怪物列表,然后返回一个元素,组成元素数组,实现循环遍历的功能。示例如下:

export const MyBook = defineComponent<MyBookProps>(props => {
    // 获取怪物列表enemys 为 CurrenEnemy 数组,可以查看 package-user/data-fallback/src/battle.ts
    const enemys = core.getCurrentEnemys();
    // 工具函数,居中,靠右,靠左对齐文字
    const central = (x: number, y: number) => [x, y, void 0, void 0, 0.5, 0.5];
    const right = (x: number, y: number) => [x, y, void 0, void 0, 1, 0.5];
    const left = (x: number, y: number) => [x, y, void 0, void 0, 0, 0.5];

    return () => (
        <Scroll>
            {/* 写一个 map 循环,将一个容器元素返回,就可以显示了 */}
            {enemys.map((v, i) => {
                return (
                    <container loc={[0, 80 * i, 480, 80]}>
                        {/* 怪物图标与怪物名称 */}
                        <icon icon={v.enemy.id} loc={[32, 16, 32, 32]} />
                        <text text={v.enemy.enemy.name} loc={central(48, 64)} />
                        {/* 显示怪物的属性 */}
                        <text text="生命" loc={right(96, 20)} />
                        <text text={v.enemy.info.hp} loc={left(108, 20)} />
                        {/* 其他的属性,例如攻击,防御等 */}
                    </container>
                );
            })}
        </Scroll>
    );
}, myBookProps);

条件判断

可以在表达式中使用三元表达式或者立即执行函数来实现条件判断:

return () => (
    <Scroll>
        {enemys.length === 0 ? (
            // 无怪物时,显示没有剩余怪物
            <text text="没有剩余怪物" loc={central(240. 240)} font={new Font('Verdana', 48)} /> // [!code ++]
        ) : (
            enemys.map(v => {
                // 有怪物时
            })
        )}
    </Scroll>
);

响应式

使用新的 UI 系统时,最大的优势就是响应式了,它可以让 UI 在数据发生变动时自动更改显示内容,而不需要手动重绘。本 UI 系统完全兼容 vue 的响应式系统,非常方便。

基础用法

例如,我想要给我的怪物手册添加一个楼层 id 的参数,首先我们先定义这个参数:

import { computed } from 'vue';

export interface MyBookProps extends UIComponentProps {
    // 定义 floorId 参数
    floorId: FloorIds;
}

const myBookProps = {
    // 这里也要修改
    props: ['controller', 'instance', 'floorId']
} satisfies SetupComponentOptions<MyBookProps>;

然后我们需要在这个参数发生变动时修改怪物列表,可以这么写:

export const MyBook = defineComponent<MyBookProps>(props => {
    // 使用 computed这样的话就会自动追踪到 props.floorId 参数,更新怪物列表,并更新显示内容
    const enemys = computed(() => core.getCurrentEnemys(props.floorId)); // [!code ++]

    return () => (
        <Scroll>
            {/* 需要使用 enemys.value 属性,不能直接使用 enemys.length */}
            {enemys.value.length === 0 ? ( // [!code ++]
                <text text="没有剩余怪物" loc={central(240. 240)} font={new Font('Verdana', 48)} />
            ) : (
                // 同上,需要 value 属性
                enemys.value.map(v => {}) // [!code ++]
            )}
        </Scroll>
    );
}, myBookProps);

什么样的变量能使用响应式

其实,我们用一般的方式编写的变量或常量都是不能使用响应式的,例如这些都不行:

let num = 10;
let str = '123';

const num2 = computed(() => num * 2);
const str2 = computed(() => parseInt(str));

这么写的话,是没有响应式效果的,这是因为 numstr 并不是响应式变量,不能追踪到。对于 string number boolean 这些字面量类型的变量,我们需要使用 ref 函数包裹才可以:

import { ref } from 'vue';

// 使用 ref 函数包裹
const num = ref(10);
// 使用 num.value 属性调用
const num2 = computed(() => num.value * 2);
// 使用 num.value 修改值
num.value = 20;

// 这样的话就有响应式效果了
<text text={num2.value.toString()} />;

对于对象类型来说,需要使用 reactive 函数包裹,这个函数会把对象变成深层响应式,任何一级发生更改都会触发响应式更新,例如:

const obj = reactive({ obj1: { num: 10 } });

// 这个就不需要使用 value 属性了,只有 ref 函数包裹的需要
obj.obj1.num = 20;

// 直接调用即可,当值更改时内容也会自动更新
<text text={obj.obj1.num.toString()} />;

数组也可以使用 reactive 方法来实现响应式:

// 传入一个泛型来指定这个变量的类型,这里使用数字数组作为示例
const array = reactive<number[]>([]);

// 可以使用数组自身的方法添加或修改元素
array.push(100);

<container>
    {/* 直接对数组遍历,数组修改后这段内容也会自动更新 */}
    {array.map(v => (
        <text text={v.toString()} />
    ))}
</container>;

如果对象比较大,只想让第一层变为响应式,深层的不变,可以使用 shallowReactiveshallowRef,或使用 markRaw 手动标记不需要响应式的部分:

// 这样的话,当 obj1.obj1.num 修改时,就不会触发响应式,而 obj1.obj1 修改时会触发
const obj1 = shallowReactive({ obj1: { num: 10 } });
// 使用 shallowRef也可以变成浅层响应式
const obj2 = shallowRef({ obj1: { num: 10 } });
// 或者手动标记为不需要响应式
const obj3 = reactive({ obj1: markRaw({ num: 10 }) });

响应式不仅可以用在 computed 或者是渲染元素中,还可以使用 watch 监听。不过该方法有一定的限制,那就是尽量不要在组件顶层之外使用。下面是一些例子:

::: code-group

const num1 = ref(10);
const num2 = ref(20);

watch(num1, (newValue, oldValue) => {
    // 当 num1 的值发生变化时,在控制台输出新值和旧值
    console.log(newValue, oldValue);

    // 这里就不是组件顶层,不要使用 watch。如果需要条件判断的话可以在监听函数内部判断而不是外部
    watch(num2, () => {});
});
const obj = reactive({
    num: 10,
    obj1: {
        num2: 20
    }
});

// 监听 obj.num
watch(
    () => obj.num,
    (newValue, oldValue) => {
        console.log(newValue, oldValue);
    }
);
// 监听 obj 整体
watch(obj, () => {
    console.log(obj.num);
});

:::

::: info 传入组件的 props 参数也是响应式的,可以通过 watch 监听,或使用 computed 追踪。 :::

关于更多 vue 响应式的知识,可以查看 Vue 官方文档

鼠标与触摸交互事件

监听鼠标或触摸

通过上面这些内容我们已经可以搭出来一个完整的怪物手册页面了不过现在这个页面是死的还没办法交互我们需要让它有办法交互允许用户点击和按键操作。UI 系统提供了丰富方便的接口来实现交互动作的监听,例如监听点击可以使用 onClick

const click = () => {
    console.log('clicked!');
};

// 直接将函数传入 onClick 属性即可
<container onClick={click}>{/* 渲染内容 */}</container>;

可以使用 cursor 属性来指定鼠标移动到该元素上时的指针样式,如下例所示,鼠标移动到这个容器上时就会变成小手的形状:

<container cursor="pointer" />

鼠标与触摸事件的触发包括两个阶段,从根节点捕获,然后一路传递到最下层,然后从最下层冒泡,然后一路再传递回根节点,一般情况下我们使用冒泡阶段的监听即可,也就是 onXxx,例如 onClick 等,不过如果我们需要监听捕获阶段的事件,也可以使用 onXxxCapture 的方法来监听:

const clickCapture = () => {
    console.log('click capture.');
};
const click = () => {
    console.log('click bubble.');
};

<container onClick={click} onClickCapture={clickCapture} />;

当点击这个容器时,就会先触发 clickCapture 事件,再触发 click 事件。

监听事件的类型

鼠标和触摸交互包含如下类型:

  • click: 当按下与抬起都发生在这个元素上时触发,冒泡阶段
  • clickCapture: 同上,捕获阶段
  • down: 当在这个元素上按下时触发,冒泡阶段
  • downCapture: 同上,捕获阶段
  • up: 当在这个元素上抬起时触发,冒泡阶段
  • upCapture: 同上,捕获阶段
  • move: 当在这个元素上移动时触发,冒泡阶段
  • moveCapture: 同上,捕获阶段
  • enter: 当进入这个元素时触发,顺序不固定,没有捕获阶段与冒泡阶段的分类
  • leave: 当离开这个元素时触发,顺序不固定,没有捕获阶段与冒泡阶段的分类
  • wheel: 当在这个元素上滚轮时触发,冒泡阶段
  • wheelCapture: 同上,捕获阶段

触发顺序如下,滚轮单独列出,不在下述顺序中:

  1. downCapture,按下捕获
  2. down: 按下冒泡
  3. moveCapture: 移动捕获
  4. move: 移动冒泡
  5. leave: 离开元素
  6. enter: 进入元素
  7. upCapture: 抬起捕获
  8. up: 抬起冒泡
  9. clickCapture: 点击捕获
  10. click: 点击冒泡

阻止事件传播

有时候我们需要阻止交互事件的继续传播,例如按钮套按钮时,我们不希望点击内部按钮时也触发外部按钮,这时候我们需要在内部按钮中阻止冒泡的继续传播。每个交互事件都可以接受一个参数,调用这个参数的 stopPropagation 方法即可阻止冒泡或捕获的继续传播:

import { IActionEvent } from '@motajs/render';

const click1 = (e: IActionEvent) => {
    // 调用以阻止冒泡的继续传播
    e.stopPropagation();
    console.log('click1');
};
const click2 = () => {
    console.log('click2');
};

<container onClick={click2}>
    <container onClick={click1}></container>
</container>;

在上面这个例子中,当我们点击内层的容器时,只会触发 click1,而不会触发 click2,只有当我们点击外层容器时,才会触发 click2,这样就成功避免了内外两个按钮同时触发的场景。

事件对象的属性

事件包含很多属性,它们定义如下,其中 IActionEventBaseenter leave 的事件对象,IActionEvent 是按下、抬起、移动、点击的事件对象,IWheelEvent 是滚轮的事件对象。

::: code-group

interface IActionEventBase {
    /** 当前事件是监听的哪个元素 */
    target: RenderItem;
    /** 是触摸操作还是鼠标操作 */
    touch: boolean;
    /**
     * 触发的按键种类,会出现在点击、按下、抬起三个事件中,而其他的如移动等该值只会是 {@link MouseType.None}
     * 电脑端可以有左键、中键、右键等,手机只会触发左键,每一项的值参考 {@link MouseType}
     */
    type: MouseType;
    /**
     * 当前按下了哪些按键。该值是一个数字,可以通过位运算判断是否按下了某个按键。
     * 例如通过 `buttons & MouseType.Left` 来判断是否按下了左键。
     * 注意在鼠标抬起或鼠标点击事件中,并不会包含触发的那个按键
     */
    buttons: number;
    /** 触发时是否按下了 alt 键 */
    altKey: boolean;
    /** 触发时是否按下了 shift 键 */
    shiftKey: boolean;
    /** 触发时是否按下了 ctrl 键 */
    ctrlKey: boolean;
    /** 触发时是否按下了 Windows(Windows) / Command(Mac) 键 */
    metaKey: boolean;
}
export interface IActionEvent extends IActionEventBase {
    /** 这次操作的标识符,在按下、移动、抬起阶段中保持不变 */
    identifier: number;
    /** 相对于触发元素左上角的横坐标 */
    offsetX: number;
    /** 相对于触发元素左上角的纵坐标 */
    offsetY: number;
    /** 相对于整个画布左上角的横坐标 */
    absoluteX: number;
    /** 相对于整个画布左上角的纵坐标 */
    absoluteY: number;

    /**
     * 调用后将停止事件的继续传播。
     * 在捕获阶段,将会阻止捕获的进一步进行,在冒泡阶段,将会阻止冒泡的进一步进行。
     * 如果当前元素有很多监听器,该方法并不会阻止其他监听器的执行。
     */
    stopPropagation(): void;
}
export interface IWheelEvent extends IActionEvent {
    /** 滚轮事件的鼠标横向滚动量 */
    wheelX: number;
    /** 滚轮事件的鼠标纵向滚动量 */
    wheelY: number;
    /** 滚轮事件的鼠标垂直屏幕的滚动量 */
    wheelZ: number;
    /** 滚轮事件的滚轮类型,表示了对应值的单位 */
    wheelType: WheelType;
}

:::

需要特别说明的是 identifier 属性,这个属性在移动端的表现没有异议,但是在电脑端,我们完全可以按下鼠标左键后,再按下鼠标右键,再按下鼠标侧键,抬起鼠标右键,抬起鼠标左键,再抬起鼠标侧键,这种情况下,我们必须单独定义 identifier 应该指代的是哪个。它遵循如下原则:

  1. 按下、抬起、点击永远保持为同一个 identifier
  2. 移动过程中,使用最后一个按下的按键的 identifier 作为移动事件的 identifier
  3. 如果移动过程中,最后一个按下的按键抬起,那么依然会维持原先的 identifer不会回退至上一个按下的按键

除此之外,滚轮事件中的 identifier 永远为 -1。

监听按键操作

注册按键命令

首先,我们应该注册一个按键命令,我们从 @motajs/system-action 中引入 gameKey 常量,在模块顶层注册一个按键命令:

import { gameKey } from '@motajs/system-action';
import { KeyCode } from '@motajs/client-base';

gameKey
    // 将后面注册的内容形成一个组,在修改快捷键时比较直观
    // 命名建议为 @ui_[UI 名称]
    .group('@ui_mybook', '示例怪物手册')
    .register({
        // 命名时,建议使用 @ui_[UI 名称]_[按键名称] 的格式
        id: '@ui_mybook_moveUp',
        // 在自定义快捷键界面显示的名称
        name: '上移一个怪物',
        // 默认按键
        defaults: KeyCode.ArrowUp
    })
    // 可以继续注册其他的,这里不再演示
    .register({});

实现按键操作

然后,我们需要从 @motajs/render 中引入 useKey 函数,然后在组件顶层这么使用:

import { useKey } from '@motajs/render';

export const MyBook = defineComponent<MyBookProps>(props => {
    // 第一个参数是按键实例,第二个参数是按键作用域,一般用不到
    const [key, scope] = useKey();

    return () => <container />;
});

最后,实现按键操作,使用 key.realize 方法:

import { clamp } from 'lodash-es';

export const MyBook = defineComponent<MyBookProps>(props => {
    const selected = ref(0); // [!code ++]
    const [key, scope] = useKey();

    // 实现按键操作,让选中的怪物索引减一 // [!code ++]
    key.realize('@ui_mybook_moveUp', () => {
        // clamp 函数是 lodash 库中的函数,可以将值限定在指定范围内 // [!code ++]
        selected.value = clamp(0, enemys.value.length - 1, selected.value - 1); // [!code ++]
    });

    return () => <container />;
});

绘制选择框与动画

定义选择框动画

下面我们来把选择框加上,当按下方向键时,选择框会移动,当按下确定键时,会打开这个怪物的详细信息。首先,我们使用一个描边格式的 g-rectr 圆角矩形元素作为选择框:

<Scroll>
    <g-rectr loc={[16, 16, 480 - 32, 480 - 32]} stroke strokeStyle="gold" />
</Scroll>

接下来,我们需要让它能够移动,当用户按下按键时,选择框会平滑移动到目标位置。这时候,我们可以使用动画接口 transitioned 来实现平滑移动。我们需要先用它定义一个动画对象:

// 这个函数在用户代码里面,直接引入
import { transitioned } from '../use';
// 从高级动画库中引入双曲速率曲线,该曲线视角效果相对较好
import { hyper } from 'mutate-animate';

// 创建一个纵坐标动画对象,初始值为 0第一个参数动画时长 150ms第二个参数
// 曲线为 慢-快-慢 的双曲正弦曲线(第三个参数)
const rectY = transitioned(0, 150, hyper('sin', 'in-out'));

然后,我们需要通过 computed 方法来动态生成圆角矩形的位置:

const rectLoc = computed(() => [
    16,
    // 使用 rectY.ref.value 获取到动画对象的响应式变量
    rectY.ref.value,
    480 - 32,
    480 - 32
]);

最后,我们把圆角矩形的 loc 属性设为 computed 值:

<Scroll>
    <g-rectr loc={rectLoc.value} stroke strokeStyle="gold" />
</Scroll>

执行动画

接下来,我们需要监听当前选中怪物,然后根据当前怪物来设置元素位置,使用 watch 监听 selected 变量:

watch(selected, value => {
    // 使用 set 方法来动画至目标值
    rectY.set(16 + value * 80);
});

除此之外,我们还可以添加当鼠标移动至怪物元素上时,选择框也移动至目标,我们需要监听 onEnter 事件:

const onEnter = (index: number) => {
    // 前面已经监听过 selected 了,这里直接设置即可,不需要再调用 rectY.set
    // 不过调用了也不会有什么影响,动画会智能处理这种情况
    selected.value = index;
};

<Scroll>
    {/* 把圆角矩形的纵深调大,防止被怪物容器遮挡 */}
    <g-rectr loc={rectLoc.value} stroke strokeStyle="gold" zIndex={10} />
    {enemys.map((v, i) => {
        // 元素内容不再展示。监听时,需要传入一个函数,因此需要使用匿名箭头函数包裹,
        // 添加 void 关键字是为了防止返回值泄漏,不过在这里并不是必要,因为 onEnter 没有返回值
        return <container onEnter={() => void onEnter(i)}></container>;
    })}
</Scroll>;

处理重叠

如果你去尝试着使用上面这个方法来实现动画,并给每个怪物添加了一个点击事件,你会发现你可能无法触发选中怪物的点击事件,这是因为 g-rectr 的纵深 zIndex 较高,交互事件会传播至此元素,而不会传播至下层元素,于是就不会触发点击事件。样板自然也考虑到了这种情况,我们只需要给圆角矩形添加一个 noevent 标识,即可让交互事件不会受到此元素的影响,不过相应地,这个元素上的交互事件也将会无法触发。示例如下:

<Scroll>
    <g-rectr
        loc={rectLoc.value}
        stroke
        strokeStyle="gold"
        zIndex={10}
        // 添加 noevent 标识事件就不会传播至此元素
        noevent // [!code ++]
    />
    {enemys.map((v, i) => {
        return <container onEnter={() => void onEnter(i)}></container>;
    })}
</Scroll>

调用 Scroll 组件接口

我们现在已经实现了按键操作,但是移动时并不能同时修改滚动条的位置,这会导致当前选中的怪物跑到画面之外,这时候我们需要自动滚动到目标位置,可以使用 Scroll 组件暴露出的接口来实现。我们使用 ref 属性来获取其接口:

import { ScrollExpose } from './components';

const scrollExpose = ref<ScrollExpose>();

<Scroll ref={scrollExpose}></Scroll>;

然后,我们可以调用其 scrollTo 方法来滚动至目标位置:

import { ScrollExpose } from './components';

const scrollExpose = ref<ScrollExpose>();

watch(selected, () => {
    // 滚动到选中怪物上下居中的位置,组件内部会自动处理滚动条边缘,因此不需要担心为负值
    scrollExpose.value.scrollTo(selected.value * 80 - 240);
});

<Scroll ref={scrollExpose}></Scroll>;

修改 UI 参数

在打开 UI 时,我们可以传入参数,默认情况下,可以传入所有的 BaseProps,也就是所有元素通用属性,以及自己定义的 UI 参数。BaseProps 内容较多,可以参考 API 文档。除此之外,我们还为这个自定义怪物手册添加了 floorId 参数,它也可以在打开 UI 时传入。如果需要打开的 UI 参数具有响应式,例如可以动态修改楼层 id可以使用 reactive 方法。示例如下:

import { MyBookProps, MyBookUI } from './myUI';

const props = reactive<MyBookProps>({
    floorId: 'MT0',
    zIndex: 100
});

mainUIController.open(MyBookUI, props);

我们可以监听状态栏更新来实时更新参数:

import { hook } from '@user/data-base';

// 监听状态栏更新事件
hook.on('updateStatusBar', () => {
    // 状态栏更新时,修改怪物手册的楼层为当前楼层 id
    props.floorId = core.status.floorId,
});

总结

通过以上的学习,你已经可以做出一个自己的怪物手册了!试着做一下吧!