diff --git a/docs/.vitepress/config.ts b/docs/.vitepress/config.ts index cadc032..cd11689 100644 --- a/docs/.vitepress/config.ts +++ b/docs/.vitepress/config.ts @@ -5,12 +5,17 @@ export default defineConfig({ title: 'HTML5 魔塔样板 V2.B', description: 'HTML5 魔塔样板 V2.B 帮助文档', base: '/_docs/', + markdown: { + math: true + }, themeConfig: { // https://vitepress.dev/reference/default-theme-config + outline: [2, 3], nav: [ { text: '主页', link: '/' }, { text: '指南', link: '/guide/diff' }, - { text: 'API', link: '/api/' } + { text: 'API', link: '/api/' }, + { text: '错误代码', link: '/logger/' } ], sidebar: { '/guide/': [ @@ -18,7 +23,12 @@ export default defineConfig({ text: '深度指南', items: [ { text: '差异说明', link: '/guide/diff' }, - { text: '系统说明', link: '/guide/system' } + { text: '系统说明', link: '/guide/system' }, + { text: 'UI 编写', link: '/guide/ui' }, + { text: 'UI 优化', link: '/guide/ui-perf' }, + { text: 'UI 系统', link: '/guide/ui-system' }, + { text: 'UI 元素', link: '/guide/ui-elements' }, + { text: 'UI 常见问题', link: '/guide/ui-faq' } ] } ] diff --git a/docs/guide/diff.md b/docs/guide/diff.md index 2e75502..0319ff6 100644 --- a/docs/guide/diff.md +++ b/docs/guide/diff.md @@ -6,7 +6,7 @@ lang: zh-CN 本文档暂时只会对新样板新增内容进行说明,其余请查看[旧样板文档](https://h5mota.com/games/template/_docs/#/)。 -本指南建立在你已经大致了解 js 的基础语法的基础上。如果还不了解可以尝试对指南内容进行模仿,或者查看[人类塔解析](https://h5mota.com/bbs/thread/?tid=1018&p=1) +本指南建立在你已经大致了解 js 的基础语法的基础上。如果还不了解 js 语法可以尝试对指南内容进行模仿,或者查看[人类塔解析](https://h5mota.com/bbs/thread/?tid=1018&p=1) 如果你有能力直接使用源码版样板进行创作,也可以直接 fork 或 clone 2.B 样板[存储库](https://github.com/unanmed/HumanBreak/tree/template-v2.B)。2.B 样板使用了 vite 作为了构建工具,同时使用了 ts 等作为了开发语言。 @@ -22,7 +22,7 @@ lang: zh-CN - 使用全新的 UI 编写方式,速度快,效率高 - 模块化,可以使用 ES6 模块化语法 - 移除插件系统,可以自定义代码目录结构,更加自由 -- 优化渲染端(client 端)与数据端(data 端)的通讯,渲染段现在可以直接引用数据端,不过数据端还不能直接引用渲染端 +- 优化渲染端(client 端)与数据端(data 端)的通讯,渲染端现在可以直接引用数据端,不过数据端还不能直接引用渲染端 ## 差异内容 diff --git a/docs/guide/img/image.png b/docs/guide/img/image.png new file mode 100644 index 0000000..bcc2908 Binary files /dev/null and b/docs/guide/img/image.png differ diff --git a/docs/guide/img/mermaid-diagram-2025-03-12-210212.svg b/docs/guide/img/mermaid-diagram-2025-03-12-210212.svg new file mode 100644 index 0000000..2105c7e --- /dev/null +++ b/docs/guide/img/mermaid-diagram-2025-03-12-210212.svg @@ -0,0 +1,3 @@ + + +是否加载 index.html加载 2.x 样板的第三方库是否在游戏中?加载渲染端入口加载数据端入口初始化数据端,写入 Mota 全局变量dataRegistered初始化渲染端clientRegisteredregistered执行数据端各个模块的初始化函数执行渲染端各个模块的初始化函数加载数据端入口初始化数据端,写入 Mota 全局变量dataRegistered 与 registered执行数据端各个模块的初始化函数执行 main.js 初始化加载全塔属性加载 core.js 及其他 libs 中的脚本coreInit开始资源加载自动元件加载完毕后触发 autotileLoaded资源加载完毕后触发 loaded进入标题界面 \ No newline at end of file diff --git a/docs/guide/system.md b/docs/guide/system.md index 2914b5d..6e3ec12 100644 --- a/docs/guide/system.md +++ b/docs/guide/system.md @@ -26,7 +26,7 @@ lang: zh-CN - [@motajs/system](../api/motajs-system) - [@motajs/system-action](../api/motajs-system-action) - [@motajs/system-ui](../api/motajs-system-ui) -- [@motajs/types](../api/types) +- [@motajs/types](../api/motajs-types) - [@user/client-modules](../api/user-client-modules) - [@user/data-base](../api/user-data-base) - [@user/data-fallback](../api/user-data-fallback) @@ -97,13 +97,11 @@ hook.on('afterBattle', enemy => { 1. 加载渲染端入口 2. 加载数据端入口 - 3. 并行初始化数据端,写入 `Mota` 全局变量 - 4. 初始化完毕后执行 `loading.emit('dataRegistered')` 钩子 - 5. 并行初始化渲染端 - 6. 初始化完毕后执行 `loading.emit('clientRegistered')` 钩子 - 7. 二者都初始化完毕后执行 `loading.emit('registered')` 钩子 - 8. 执行数据端各个模块的初始化函数 - 9. 执行渲染段各个模块的初始化函数 + 3. 并行初始化数据端与渲染端,在数据端写入 `Mota` 全局变量 + 4. 数据端初始化完毕后执行 `loading.emit('dataRegistered')` 钩子,渲染端初始化完毕后执行 `loading.emit('clientRegistered')` 钩子 + 5. 二者都初始化完毕后执行 `loading.emit('registered')` 钩子 + 6. 执行数据端各个模块的初始化函数 + 7. 执行渲染端各个模块的初始化函数 4. 如果是录像验证中: @@ -121,6 +119,10 @@ hook.on('afterBattle', enemy => { 11. 资源加载完毕后执行 `loading.emit('loaded')` 钩子 12. 进入标题界面 +使用流程图表示如下: + + + ## 函数重写 在 2.B 模式下,如果想改 `libs` 的内容,如果直接在里面改会很麻烦,而且两端通讯也不方便,因此我们建议在 `package-user` 中对函数重写,这样的话就可以使用模块化语法,更加方便。同时,2.B 也提供了函数重写接口,他在 `@motajs/legacy-common` 模块中,我们可以这么使用它: @@ -146,7 +148,7 @@ export function patchMyFunctions() { } ``` -然后,我们找到 `client-modules` 文件夹下的 `index.ts` 文件,然后在 `create` 函数中调用 `patchMyFunctions`,这样我们的函数重写就完成了。 +然后,我们找到 `client-modules` 文件夹下的 `index.ts` 文件,然后在 `create` 函数中引入并调用 `patchMyFunctions`,这样我们的函数重写就完成了。**注意**,如果两个重写冲突,会在控制台弹出警告,并使用最后一次重写的内容。 ::: warning **注意**,在渲染端重写的函数在录像验证中将无效,因为录像验证不会执行任何渲染端内容! @@ -155,3 +157,32 @@ export function patchMyFunctions() { ## 目录结构 我们建议每个文件夹中都有一个 `index.ts` 文件,将本文件夹中的其他文件经由此文件导出,这样方便管理,同时结构清晰。可以参考 `packages-user/client-modules` 文件夹中是如何做的。 + +## ES6 模块化语法 + +我们推荐使用 ES6 模块化语法来编写代码,这会大大提高开发效率。下面来简单说明一下模块化语法的用法,首先是引入其他模块: + +```ts +import { Patch } from '@motajs/legacy-common'; // 从样板库中引入接口 +// 引入本地文件,注意不要填写后缀名,只可以在同一个 packages-user 子文件夹下使用 +// 不可以跨文件夹使用,例如 packages-user/client-modules 就不能直接引用 packages-user/data-base 文件夹 +// 需要使用 import { ... } from '@user/data-base' +import { patchMyFunctions } from './override'; +``` + +然后是从当前模块导出内容: + +```ts +// 导出函数 +export function myFunc() { ... } +// 导出变量/常量 +export const num = 100; +// 导出类 +export class MyClass { ... } +// 从另一个模块中导出全部内容,即将另一个模块的内容转发为当前模块 +export * from './xxx'; +``` + +更多模块化语法内容请查看[这个文档](https://h5mota.com/bbs/thread/?tid=1018&p=3#p33) + +与 TypeScript 相关语法请查看[这个文档](https://h5mota.com/bbs/thread/?tid=1018&p=3#p41) diff --git a/docs/guide/ui-elements.md b/docs/guide/ui-elements.md new file mode 100644 index 0000000..9e35192 --- /dev/null +++ b/docs/guide/ui-elements.md @@ -0,0 +1,754 @@ +# UI 元素 + +本节将会讲解 UI 系统中常用的渲染元素以及基础使用。 + +## 通用属性 + +UI 元素包含很多通用属性,我们先来介绍这些属性,它们可以用在任何渲染元素和 UI 组件中。 + +### 定位属性 + +元素包含若干定位属性,其中最常用的是 `loc` 属性,我们也推荐全部使用这个属性来修改元素定位。其类型声明如下: + +```ts +type ElementLocator = [ + x?: number, + y?: number, + w?: number, + h?: number, + ax?: number, + ay?: number +]; +``` + +这些属性两两组成一组(`x, y` 一组,`w, h` 一组,`ax, ay` 一组),每组可选填,也就是说 `x` 和 `y` 要么都填,要么都不填,以此类推。 + +- `x` `y`: 元素的位置,描述了在没有旋转时元素的锚点位置,例如 `[32, 32]` 就表示这个元素锚点在 `32, 32` 的位置,默认锚点在元素左上角,也就表示元素左上角在 `32, 32`。 +- `w` `h`: 元素的长宽,描述了在没有缩放时元素的矩形长宽,默认是没有放缩的。 +- `ax` `ay`: 元素的锚点位置,描述了元素参考点的位置,所有位置变换等将以此点作为参考点。0 表示元素最左侧或最上侧,1 表示最右侧或最下侧,可以填不在 0-1 范围内的值,例如 `[-1, 1]` 表示锚点横坐标在元素左侧一个元素宽度的位置,纵坐标在元素下边缘的位置。 + + + +示例如下: + +```tsx +// 元素相对于 32, 32 位置居中(锚点在元素正中间),宽高为 64 + +``` + +除了 `loc` 属性之外,还可以通过设置 `anc` 属性来修改锚点位置,示例如下: + +```tsx +// 设置锚点,效果为靠右对齐,上下居中对齐 + +``` + +你还可以手动指定 `x` `y` `width` `height` `anchorX` `anchorY` 属性,但是这种方式比较啰嗦,并不建议使用: + +```tsx + +``` + +最后说明一下元素的 `type` 属性,此属性描述了元素的定位模式,默认为 `static` 常规定位,此定位模式下元素位置会按照上述内容更改,而在 `absolute` 模式下,不论怎么修改定位属性,它都会保持在左上角的位置,可能会在一些特殊场景下使用(极度不建议使用此属性,很可能在 2.B.1 版本就会将其删除)。 + +### 纵深属性 + +可以通过 `zIndex` 属性来调整一个元素的纵深。纵深描述了元素之间的重叠关系,纵深高的会处在纵深低的元素上方,同时也会阻碍交互事件向纵深低的元素传播。必要的时候,需要通过设置纵深属性来调整层级关系。未设置时,后面的元素会处在前面的元素之上。 + +```tsx +// 这个元素会处在上层 + +// 这个元素会处在下层 + +``` + +### 效果属性 + +效果属性包含 `filter` `composite` 及 `alpha` 三个属性。 + +`filter` 表示此元素的滤镜,参考 [CanvasRenderingContext2D.filter](https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter),可以填写内置函数或 svg 滤镜。默认不包含任何滤镜。示例如下: + +```tsx +// 亮度变为 150%,对比度变为 120% + +``` + +`composite` 属性描述了当前元素与在此之前渲染的元素之间的混合模式,参考 [CanvasRenderingContext2D.globalCompositeOperation](https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation),可以填写 26 个值。默认使用简单 `alpha` 混合,即 `source-over`。例如: + +```tsx +// 使用加算方式叠加,两个颜色的 RGB 值分别相加得到最终结果 + +``` + +`alpha` 属性描述了此元素的不透明度,1 表示完全不透明,0 表示完全透明。在叠加时,所有颜色都会乘以此不透明度后叠加。默认值是完全不透明,即 1。但需要注意的是,虽然 1 表示完全不透明,但是如果画布内容本身包含透明内容(例如一个半透明矩形),即使是 1 也会表现为透明,因为叠加时会乘以 1,不透明度不变。示例如下: + +```tsx +// 一个半透明元素 + +``` + +### 缓存属性 + +可以通过 `cache` 和 `nocache` 属性来指定这个元素的缓存行为,其中 `nocache` 表示禁用此元素的缓存机制,优先级最高,设置后必然不使用缓存。`cache` 表示启用此元素的缓存行为,常用于一些默认不启用缓存的元素,优先级低于 `cache`。这两个元素都不能动态设置,也就是说不能使用响应式来修改其值。示例如下: + +```tsx +// 内部渲染内容比较简单,不需要启用缓存 + + + +// 路径较为复杂,因此启用 g-path 的缓存行为 + +``` + +### 元素溢出行为 + +溢出行为是指,当其子元素超出父元素的大小时,执行的行为。例如,假如父元素大小为 `200 * 200`,里面有一个子元素,大小为 `100 * 100`,位于 `(150, 50)` 的位置,这时候子元素的一部分就会超出父元素的范围。 + +在本渲染系统中,所有元素的默认溢出行为是裁剪,即不会显示任何溢出内容,注意调整容器的宽高。在 `nocache` 模式下,由于不受到缓存的约束,溢出内容依然会显示,不过不建议利用此特性来编写 UI,因为这种行为可能会在后续的更新中修改。 + +### 隐藏元素 + +可以使用 `hidden` 属性来隐藏元素: + +```tsx +const hidden = ref(false); +// 一般使用一个响应式变量来控制隐藏行为,因为设置成常量没有任何意义 +; +``` + +### 交互属性 + +交互属性包括 `cursor` 和 `noevent`。前者描述了鼠标覆盖在当前元素上时的指针样式,参考 [CSS: cursor](https://developer.mozilla.org/zh-CN/docs/Web/CSS/cursor)。示例如下: + +```tsx +// 鼠标放置在该元素上时使用小手样式 + +``` + +`noevent` 表明当前元素将不会触发任何事件,事件将会下穿至纵深更低的元素。示例如下: + +```tsx +// 设置为 noevent 模式 + +// 这样的话这个 onClick 就可以正常触发了 + +``` + +### 高清与抗锯齿 + +包含 `hd` `anti` `noanti` 三个属性,`hd` 表示是否启用高清,大部分元素是默认启用的,除了几个像素风为主的元素(地图渲染等);`anti` 表示手动启用画布的抗锯齿行为,一般用于默认不启用抗锯齿的元素;`noanti` 表示手动关闭元素的抗锯齿行为,优先级高于 `anti`,一般用于像素风图片展示、图标显示等,同时也有助于提高渲染性能。 + +```tsx +// 关闭高清 + +// 关闭抗锯齿 + +// 启用地图渲染的抗锯齿 + +``` + +### 元素变换属性 + +可以通过调整 `transform` 属性来修改元素的线性变换,包括平移、旋转、缩放。如果是简易的变换,可以使用 `rotate` `scale` 属性来修改旋转、缩放,使用 `loc` 来修改位置: + +```tsx +// 旋转 90 度,横向放缩为 1.5 倍,纵向不放缩 + +``` + +我们没有设置锚点属性,那么需要注意旋转后,`loc` 属性所标明的位置将不再是左上角的那个点,因为旋转后,原本在左上角的点将会变成右上角的点。旋转时,顺时针为正,逆时针为负。 + +使用上面这种方式时,没有办法指定变换的顺序。一般情况下,变换的顺序将会影响结果,例如先旋转,再放缩,和先放缩,再旋转,其结果是不同的。下面我们来讲解一下图形学中的 2D 矩阵变换的基本概念,以及如何使用 `transform` 属性。 + +### Transform 矩阵变换 + +大部分情况下用不到此属性,此属性理解难度较大,如果不是必须使用此属性,可以不看此小节。以下矩阵变换内容由 `DeepSeek R1` 模型生成,并稍作修改。 + +#### 为什么需要变换矩阵? + +在 2D 图形学中,变换矩阵(3x3)可以统一表示以下基本变换操作: + +- 平移(Translation) +- 旋转(Rotation) +- 缩放(Scale) +- 错切(Skew) + +通过矩阵乘法可以将多个变换组合为单个矩阵运算,其通用数学表示为(列主序): + +$Transform=\begin{bmatrix} a & b & 0 \\ c & d & 0 \\ e & f & 1 \end{bmatrix}$ + +其中: + +- `a,d` 控制缩放和旋转 +- `b,c` 控制错切 +- `e,f` 控制平移 + +#### 变换组合原理 + +矩阵乘法具有结合性但不具有交换性,变换顺序会影响最终效果,矩阵按从右到左的顺序应用变换: + +最终矩阵 = 平移矩阵 × 旋转矩阵 × 缩放矩阵 × 原始坐标 + +#### Transform 类核心功能 + +首先创建其实例: + +```ts +import { Transform } from '@motajs/render'; + +const trans = new Transform(); +``` + +之后可以链式调用来修改矩阵: + +```ts +// 链式调用示例 +trans + .setTranslate(100, 50) + .rotate(Math.PI / 4) + .scale(2, 1.5); +``` + +方法对比 + +| 方法类型 | 特点 | 函数 | +| -------- | -------------------------- | ---------------------------------------------------- | +| 叠加变换 | 在现有变换基础上叠加新变换 | `translate` `rotate` `scale` `transform` | +| 直接设置 | 覆盖当前变换参数 | `setTranslate` `setRotate` `setScale` `setTransform` | + +#### 关键方法详解 + +平移变换: + +```ts +// 相对移动(叠加) +trans.translate(20, -10); +// 绝对定位(覆盖) +trans.setTranslate(200, 150); +``` + +旋转变换: + +```ts +// 叠加旋转 45 度 +trans.rotate(Math.PI / 4); +// 设置绝对旋转角度 +trans.setRotate(Math.PI / 2); +``` + +缩放变换: + +```ts +// X 轴放大 2 倍,Y 轴不变(叠加) +trans.scale(2, 1); +// 设置绝对缩放比例 +trans.setScale(0.5, 0.8); +``` + +#### 高级功能 + +矩阵操作: + +```ts +// 手动设置变换矩阵 +trans.setTransform( + 1, + 0, // 缩放部分 + 0, + 1, // 旋转部分 + 100, + 50 // 平移部分 +); +// 矩阵相乘(组合变换) +const combined = trans.multiply(otherTransform); +``` + +坐标变换: + +```ts +// 将局部坐标转换为世界坐标,即计算一个坐标经过此变换矩阵计算后的位置 +const worldPos = trans.transformed(10, 20); +// 将世界坐标转换回局部坐标,即计算一个坐标经过此变换矩阵逆转换后的位置 +const localPos = trans.untransformed(150, 80); +``` + +#### 性能优化技巧 + +使用自动更新机制: + +```ts +import { ITransformUpdatable } from '@motajs/render'; + +// 绑定可更新对象 +class MyElement implements ITransformUpdatable { + updateTransform() { + console.log('变换已更新!'); + } +} + +const element = new MyElement(); +trans.bind(element); // 变换修改时自动触发 updateTransform +``` + +#### 最佳实践 + +推荐变换执行顺序: + +1. 缩放(Scale) +2. 旋转(Rotate) +3. 平移(Translate) + +```ts +// 正确顺序示例 +trans + .setScale(2) + .rotate(Math.PI / 3) + .setTranslate(100, 50); +``` + +组合复杂变换: + +```ts +// 创建子变换 +const childTrans = trans + .clone() // 从 trans 复制一个相同的变换出来,以防止修改原变换 + .rotate(-Math.PI / 6) + .translate(30, 0); + +// 应用组合变换 +const finalTrans = trans.multiply(childTrans); +``` + +#### 应用到元素 + +赋值给 `transform` 属性即可 + +```tsx + +``` + +#### 常见问题排查 + +1. 变换不生效? + + - 验证绑定的对象是否实现 `updateTransform` + - 检查有没有把 `trans` 对象赋值给元素的 `transform` 属性 + +2. 性能问题 + + - 避免高频调用 `setTransform` + - 优先使用叠加方法代替矩阵直接操作 + - 利用 `clone()` 复用已有变换 + +## `sprite` 标签 + +`sprite` 标签是一个允许你自定义渲染内容的标签,它新增了一个属性 `render` 属性,允许你传入一个函数来执行自定义渲染。函数定义如下: + +```ts +type RenderFn = (canvas: MotaOffscreenCanvas2D, transform: Transform) => void; +``` + +- `canvas`: 要渲染至的画布,一般直接将内容渲染至这个画布上 +- `transform`: 当前元素的变换矩阵,相对于父元素,不常用 + +多数情况下,我们只会使用到第一个参数,`MotaOffscreenCanvas2D` 接口请参考 [API 文档](../api/motajs-render-core)。下面是一个典型案例: + +```tsx +const render = (canvas: MotaOffscreenCanvas2D) => { + const { ctx, width, height } = canvas; // 获取画布上下文以及长宽 + ctx.fillStyle = '#d84'; // 设置填充样式 + ctx.fillRect(0, 0, width, height); // 绘制一个矩形 +}; +``` + +`sprite` 元素的应用场景并不算多,因为样板内置的各种元素已经足够丰富,此元素一般只会在一些特殊情况下或性能敏感情况下使用。 + +## `container` 标签 + +`container` 表示一个容器,它可以将一系列元素作为子元素并渲染。它并没有新增任何属性。如果你想渲染子元素,请务必使用此元素包裹,除此之外的大部分元素是不能渲染子元素的。 + +## `container-custom` 标签 + +`container-custom` 是另一种容器,它允许你自定义对子元素的渲染方案,对于特殊场景下有一定的作用,例如样板自带的 `Scroll` 组件就使用此标签实现。它新增了一个 `render` 参数,定义如下: + +```ts +type CustomContainerRenderFn = ( + canvas: MotaOffscreenCanvas2D, + children: RenderItem[], + transform: Transform +) => void; +``` + +- `canvas`: 要渲染至的画布,一般直接将内容渲染至这个画布上 +- `children`: 要渲染的子元素,按 `zIndex` 升序排列 +- `transform`: 当前元素的变换矩阵,相对于父元素,不常用 + +典型案例如下: + +```tsx +const render = ( + canvas: MotaOffscreenCanvas2D, + children: RenderItem[], + transform: Transform +) => { + // 顺序遍历子元素,保证纵深关系正确 + children.forEach(v => { + if (v.hidden) return; // 如果元素隐藏,则不渲染 + // 调用子元素的渲染函数,传入 canvas 和 transform 作为参数 + v.renderContent(canvas, transform); + }); +}; + +; +``` + +## `text` 标签 + +`text` 标签用于显示文字,它会自动计算文字的宽高并设置为元素宽高,因此不要手动指定宽高,否则可能会引起位置错误。它新增了这些属性: + +```ts +interface TextProps extends BaseProps { + /** 要渲染的文字 */ + text?: string; + /** 文字的填充样式 */ + fillStyle?: CanvasStyle; + /** 文字的描边样式 */ + strokeStyle?: CanvasStyle; + /** 文字的字体 */ + font?: Font; + /** 文字的描边粗细 */ + strokeWidth?: number; +} +``` + +典型案例如下: + +```tsx +import { Font } from '@motajs/render'; + +; +``` + +## `image` 标签 + +`image` 标签允许你显示一张图片,包含一个 `image` 属性,传入图片对象(注意不是注册图片名称)。用例如下: + +```tsx +// 获取注册的图片 +const img = core.material.images.images['myImage.png']; +// 显示图片 +; +``` + +## `icon` 标签 + +`icon` 标签用于显示一个图标,可以包含动画帧。它有如下参数: + +```ts +export interface IconProps extends BaseProps { + /** 图标 id 或数字 */ + icon: AllNumbers | AllIds; + /** 显示图标的第几帧 */ + frame?: number; + /** 是否开启动画,开启后 frame 参数无效 */ + animate?: boolean; +} +``` + +使用案例如下: + +```tsx +// 显示绿史莱姆,开启动画 + +``` + +## `winskin` 标签 + +`winskin` 标签允许你显示一个 rpg maker 风格的背景图(window skin),它有如下参数: + +```ts +export interface WinskinProps extends BaseProps { + /** winskin 的图片 id */ + image: ImageIds; + /** 边框大小 */ + borderSize?: number; +} +``` + +其中边框大小默认为 32,表示上边框和下边框加起来共 32 像素,即四周边框 16 像素宽。用例如下: + +```tsx +// 使用 winskin.png 作为图片,四周边框宽度为 24 像素 + +``` + +## 图形标签 + +本小节讲解图形相关的标签,以下内容由 `DeepSeek R1` 模型生成并稍作修改。 + +### 通用属性说明 + +所有图形元素均支持以下核心属性: + +| 属性分类 | 关键参数 | 说明 | +| -------------- | ------------------------- | -------------------------------------------------------- | +| **填充与描边** | `fill` `stroke` | 控制是否填充/描边(不同元素默认值不同) | +| **样式控制** | `fillStyle` `strokeStyle` | 填充和描边样式,支持颜色/渐变等(如 `'#f00'`) | +| **线型设置** | `lineWidth` `lineDash` | 线宽、虚线模式(如 `[5, 3]` 表示 5 像素实线+3 像素间隙) | +| **高级控制** | `fillRule` `actionStroke` | 填充规则(非零/奇偶)、是否仅在描边区域响应交互 | + +### 矩形 `` + +矩形的定位直接使用 `loc` 即可,示例如下: + +```tsx +// 基础矩形,矩形默认仅填充模式,因此如果需要描边的话需要手动指定 fill 和 stroke 参数 +// 注意如果仅指定 stroke 参数的话,会变为仅描边形式 + +``` + +### 圆形和扇形 `` + +参数如下: + +```ts +interface CirclesProps { + radius: number; // 半径 + start?: number; // 起始弧度(默认0) + end?: number; // 结束弧度(默认2π) + /** + * 圆属性参数,可以填 `[圆心 x 坐标,圆心 y 坐标,半径,起始角度,终止角度]`,是 x, y, radius, start, end 的简写, + * 其中半径可选,后两项要么都填,要么都不填 + */ + circle?: CircleParams; +} +``` + +示例如下: + +```tsx +// 完整圆形 + +// 扇形(60度到180度) + +``` + +### 直线 `` + +核心参数: + +```ts +interface LineProps { + x1: number; // 起点X + y1: number; // 起点Y + x2: number; // 终点X + y2: number; // 终点Y + /** 直线属性简写参数,可以填 `[x1, y1, x2, y2]`,都是必填 */ + line: [number, number, number, number]; +} +``` + +示例如下: + +```tsx +// 普通直线 + + +// 带箭头的参考线 + +``` + +### 三次贝塞尔曲线 `` + +核心参数: + +```ts +interface BezierProps { + sx: number; // 起点X + sy: number; // 起点Y + cp1x: number; // 控制点1X + cp1y: number; // 控制点1Y + cp2x: number; // 控制点2X(三次贝塞尔) + cp2y: number; // 控制点2Y + ex: number; // 终点X + ey: number; // 终点Y + /** 三次贝塞尔曲线参数简写,可以填 `[sx, sy, cp1x, cp1y, cp2x, cp2y, ex, ey]`,都是必填 */ + curve: BezierParams; +} +``` + +示例如下: + +```tsx +// 三次贝塞尔曲线 +; + +// 动态路径 +const path = computed(() => [ + startX.value, + startY.value, + control1X.value, + control1Y.value, + control2X.value, + control2Y.value, + endX.value, + endY.value +]); +; +``` + +### 二次贝塞尔曲线 `` + +核心参数: + +```ts +interface BezierProps { + sx: number; // 起点X + sy: number; // 起点Y + cpx: number; // 控制点X + cpy: number; // 控制点Y + ex: number; // 终点X + ey: number; // 终点Y + /** 二次贝塞尔曲线参数,可以填 `[sx, sy, cpx, cpy, ex, ey]`,都是必填 */ + curve: QuadParams; +} +``` + +示例如下: + +```tsx +// 二次贝塞尔曲线 +; + +// 动态路径 +const path = computed(() => [ + startX.value, + startY.value, + controlX.value, + controlY.value, + endX.value, + endY.value +]); +; +``` + +### 圆角矩形 `` + +圆角矩形的核心参数与 CSS 的 border-radius 类似,如下: + +```ts +interface RectRProps extends GraphicPropsBase { + /** + * 圆形圆角参数,可以填 `[r1, r2, r3, r4]`,后三项可选。填写不同数量下的表现: + * - 1个:每个角都是 `r1` 半径的圆 + * - 2个:左上和右下是 `r1` 半径的圆,右上和左下是 `r2` 半径的圆 + * - 3个:左上是 `r1` 半径的圆,右上和左下是 `r2` 半径的圆,右下是 `r3` 半径的圆 + * - 4个:左上、右上、左下、右下 分别是 `r1, r2, r3, r4` 半径的圆 + */ + circle?: RectRCircleParams; + /** + * 椭圆圆角参数,可以填 `[rx1, ry1, rx2, ry2, rx3, ry3, rx4, ry4]`, + * 两两一组,后三组可选,填写不同数量下的表现: + * - 1组:每个角都是 `[rx1, ry1]` 半径的椭圆 + * - 2组:左上和右下是 `[rx1, ry1]` 半径的椭圆,右上和左下是 `[rx2, ry2]` 半径的椭圆 + * - 3组:左上是 `[rx1, ry1]` 半径的椭圆,右上和左下是 `[rx2, ey2]` 半径的椭圆,右下是 `[rx3, ry3]` 半径的椭圆 + * - 4组:左上、右上、左下、右下 分别是 `[rx1, ry1], [rx2, ry2], [rx3, ry3], [rx4, ry4]` 半径的椭圆 + */ + ellipse?: RectREllipseParams; +} +``` + +示例如下: + +```tsx +// 四角圆角半径都为 10 的圆角矩形 + +// 每个角都是横向半径为 10,纵向半径为 5 的椭圆 + +// 左上和右下是半径为 10 的圆角,左下和右上是半径为 25 的圆角 + +``` + +### 自定义路径 `` + +核心参数: + +```ts +interface PathProps { + path?: Path2D; // 自定义路径对象 +} +``` + +示例: + +```tsx +// 创建五角星 +const starPath = new Path2D(); +// ...路径绘制逻辑 +; +``` + +### 最佳实践建议 + +1. 交互增强: + +```tsx + +``` + +2. 样式复用: + +```tsx +// 创建样式对象 +const themeStyle = { + fillStyle: '#2c3e50', + strokeStyle: '#ecf0f1', + lineWidth: 2 +}; + + + +``` diff --git a/docs/guide/ui-faq.md b/docs/guide/ui-faq.md new file mode 100644 index 0000000..53a16fc --- /dev/null +++ b/docs/guide/ui-faq.md @@ -0,0 +1,61 @@ +# UI 常见问题 + +## 为什么我的 UI 不显示? + +检查 UI 的纵深(zIndex)是否符合预期,有没有被其他元素遮挡;检查当前元素是否处于 `hidden` 状态。可以在控制台输入 `logTagTree()` 来输出当前的渲染树 `xml` 标签结构,会包含一些重要信息。 + +第二种可能性是你的元素处在了父元素范围之外,导致被裁剪掉。注意,`transform` 属性是对元素本身的变换,这也会导致元素本身的矩形范围发生变化,如果你的元素设置了缩放、旋转等,需要考虑此属性对位置的影响。 + +## 为什么我的元素 onClick 事件没办法触发? + +可能你的元素被纵深更高的元素覆盖,导致事件无法传播至你的元素,考虑在纵深更高的元素上添加 `noevent` 标识来禁用它的事件传播。注意,一个纯透明的元素也可能会覆盖你的元素,仔细查看你的渲染树结构。 + +第二种可能性是其子元素拦截了冒泡或是其父元素拦截了捕获,检查 `e.stopPropagation` 调用情况。 + +## 我的数据更新后,为什么渲染内容没有更新? + +你可能在使用 `sprite` 元素,然后在渲染函数里面调用了外部数据,这样的话当外部数据更新时,你的 `sprite` 元素并不会自动更新,需要手动更新。手动更新参考代码: + +```tsx +import { Sprite } from '@motajs/render'; + +const mySprite = ref(); +// 数据更新时,同时更新 sprite 元素 +watch(data, () => mySprite.value?.update()); + +// 将 mySprite 传入 ref 参数,这样当挂载完毕后就会将 mySprite.value 设置为该元素 +; +``` + +除此之外还可能你的数据不是响应式数据,确保你的数据经过了 `reactive` 或 `ref` 包裹。 + +## 我的 UI 很卡 + +可能使用了平铺式布局,建议使用 `Scroll` 组件或者 `Page` 组件来对平铺内容分割,从而提高渲染效率。可以参考对应的 [API 文档](../api/user-client-modules/Scroll)。 + +## 玩着玩着突然黑屏了一下,然后画面就不显示了 + +你应该遇到了内存泄漏问题,当一个元素被卸载后,它应该会被销毁,但是如果没有被预期销毁,那么会导致内存泄漏,最终导致爆显存,就会导致画面黑屏一下,然后内容就会不显示。样板本身已经针对这个问题进行了处理,一般情况下不会出现问题,出现这个问题时大概率是你自己的组件或 UI 有问题。可能原因有很多,例如你声明了一个列表,当组件挂载时将元素放入列表,但是当组件卸载时,你却没有将元素移除,这时候就会导致这个元素无法正确被垃圾回收,从而引起内存泄漏。 + +关于这个问题的最佳实践: + +- 如果你手动存储了一些元素,确保在卸载时将它们删除 +- 在删除它们的同时,调用它们的 `destroy` 方法,来确保可以被垃圾回收 +- 在控制台输入 `Mota.require('@motajs/render').MotaOffscreenCanvas2D.list` 来查看当前还有哪些画布正在使用,游玩一段时间后再次输入,检查数量是否增长,如果增长,说明发生了内存泄漏 +- 确保组件卸载时已经清空了定时器等内容 +- 如果需要每帧执行函数,请使用 `onTick` 接口,而非其他方法 + +如果你直接使用 `MotaOffscreenCanvas2D` 接口,请确保: + +- 在使用前调用了 `activate` 方法 +- 在使用后调用了 `deactivate` 方法 +- 如果不需要再修改画布属性,只需要绘制,请调用 `freeze` 方法 +- 如果之后不再使用该画布,请调用 `destroy` 方法 + +## 为什么我的滤镜不显示? + +很遗憾,截止目前(2.B 发布日期),IOS 依然没有支持 `CanvasRenderingContext2D` 上的 `filter` 方法,所有滤镜属性在 IOS 上将不会显示。不过,我们提供了 `Shader` 元素,它使用 `WebGL2` 接口,允许你制作自己的滤镜,如果滤镜是必要的,请考虑使用此元素,但是需要一定的图形学基础,可以在造塔群询问我或造塔辅助 AI。 + +## 不同设备的显示内容会不一样吗? + +从理论上来讲,除了上面那个问题提到的滤镜,其他的所有内容的渲染结果应该完全一致,如果出现了不一致的情况,请上报样板 bug。 diff --git a/docs/guide/ui-perf.md b/docs/guide/ui-perf.md new file mode 100644 index 0000000..1c91895 --- /dev/null +++ b/docs/guide/ui-perf.md @@ -0,0 +1,142 @@ +--- +lang: zh-CN +--- + +# UI 优化指南 + +多数情况下,我们编写的简单 UI 并不需要特别的性能优化,渲染系统的懒更新及缓存机制已经可以有很优秀的性能表现,不过我们还是可能会遇到一些需要特殊优化的场景,本节将会讲述如何优化 UI 的性能表现,优化建议包括:避免元素平铺;使用 `Scroll` 或 `Page` 组件优化平铺性能;避免元素自我更新;使用 `cache` 和 `nocache` 标识;特殊场景禁用抗锯齿和高清;在合适场景下隐藏一些元素等。 + +## 避免元素平铺 + +在不使用 `Scroll` 组件时,我们需要尽量避免元素平铺,因为这会导致更新时渲染次数上升,从而引起性能下降。我们建议善用树形结构的缓存特性,将可以作为一个整体的元素使用一个容器 `container` 包裹起来,减少更新时的渲染次数,尤其是对于那些不常更新的元素来说,更应该使用容器包裹。不过我们也不建议嵌套过深,这可能导致浪费在递归渲染上的时间过长,渲染效率变低。 + +画布渲染树的深度遍历特性使得: + +- 每个独立容器的更新会触发子树的重新渲染 +- 容器层级过深会增加递归调用栈开销 +- 合理分组可将高频/低频更新元素隔离 + +下面是代码示例: + +```tsx +// ❌ 差的写法,全部平铺在一个容器里 + + + {/* 中间省略 998 个元素 */} + + + +// ✅ 好的写法 + + {/* 把不常更新的单独放到一个容器里面 */} + + + {/* 中间省略 988 个元素 */} + + + {/* 把常更新的单独放到一个容器里面 */} + + + {/* 中间省略 8 个元素 */} + + + +``` + +## 使用 `Scroll` 或 `Page` 组件优化平铺性能 + +在一些特殊情况下,我们不得不使用平铺布局,例如上一节提到的怪物手册,或是展示一个列表等,这时候必须平铺元素。这时候我们可以使用 `Scroll` 组件或 `Page` 组件来优化性能表现。`Scroll` 组件中,只有在画面内的元素会被渲染,而画面外的不会被渲染,这会大大提高渲染效率;`Page` 组件允许你把列表拆分成多个部分,然后把内容放在不同页中,从而提高渲染性能。极端情况下,`Page` 组件的渲染效率要明显高于 `Scroll` 组件,但是滚动条对于交互更友好,我们推荐在简单场景下使用 `Scroll` 组件,而对于复杂场景,换为 `Page` 组件。两个组件的使用方式可以参考 [API 文档](../api/motajs-render-elements/)。 + +我们建议: + +1. **优先使用 Scroll**: + - 元素数量 < 500 + - 需要流畅滚动交互 + - 元素高度不固定 +2. **切换至 Page**: + - 元素数量 > 1000 + - 需要支持快速跳转 + - 存在复杂子组件(如嵌套动画) + +下面是代码示例: + +```tsx +// ❌ 差的写法,全部平铺在一个容器里 + + + {/* 中间省略 998 个元素 */} + + + +// ✅ 好的写法,使用 Scroll 组件优化 + + + {/* 中间省略 998 个元素 */} + + + +// ✅ 好的写法,使用 Page 组件优化 + + {(page: number) => { + return list.slice(page * 10, (page + 1) * 10).map(v => ) + }} + +``` + +## 避免元素自我更新 + +元素自我更新是指,在元素的渲染函数内,触发了元素的冒泡更新,这会导致更新无限循环,而且难以察觉。为了解决难以察觉的问题,我们使用了一种方式来专门探测这种情况。常见的触发元素自我更新的场景就是使用 `sprite` 元素,例如: + +```tsx +const element = ref(); +const render = () => { + element.value?.update(); +}; + +; +``` + +在上面这段渲染代码中,`sprite` 元素的渲染函数又再次触发了自我更新,这会导致更新无限循环。在开发环境下,这种情况会在控制台抛出警告:`Unexpected recursive call of Sprite.update?uid in render function. Please ensure you have to do this, if you do, ignore this warn.`,这会告诉你是哪个类型的元素触发了循环更新,以及对应元素的 `uid`,从而帮助你寻找问题所在。不过,样板还是留出了一个口子,如果你必须使用循环更新,那么你可以忽略此条警告,在网站上游玩时这条警告将不会被触发,游戏会正常运行。 + +## 使用 `cache` 和 `nocache` 标识 + +`cache` 和 `nocache` 表示可以让你更加精确地控制渲染树的缓存行为,从而更好地优化渲染性能。默认情况下,这些元素是会被缓存的:`container` `container-custom` `template` `sprite` `image` `icon` `layer` `layer-group` `animation`,对于这些元素,你可以使用 `nocache` 标识来禁用它们的缓存,对于其本身或其子元素的渲染较为简单的场景,禁用缓存后渲染效率可能会更高。其他元素默认是禁用缓存的,如果你的渲染内容比较复杂,例如 `g-path` 元素的路径很复杂,可以使用 `cache` 表示来启用缓存,从而提高渲染效率。示例代码如下: + +```tsx +const render = (canvas: MotaOffscreenCanvas2D) => { + canvas.ctx.fillRect(0, 0, 200, 200); +}; +// ❌ 差的写法,一个简单的矩形绘制,但是 sprite 默认启用缓存,可能会拉低渲染效率 +; + +// ✅ 好的写法,使用 nocache 标识禁用 sprite 的缓存机制 +; +``` + +## 特殊场景禁用抗锯齿和高清 + +默认情况下,大部分元素都是默认启用高清即抗锯齿的(`layer` 和 `layer-group` `icon` 不启用),这可能会导致一些不必要的计算出现,从而拉低渲染性能。对于一些需要保持像素风的内容,我们建议关闭抗锯齿和高清画布。代码示例如下: + +```tsx +// ❌ 差的写法,像素风图片使用默认设置,启用了抗锯齿和高清 + +// ✅ 好的写法,关闭了默认的抗锯齿和高清 + +``` + +## 在合适场景下隐藏一些元素 + +如果一个元素在某些场景下需要隐藏,另一些场景下需要显示,我们建议使用 `hidden` 属性来设置,而不是通过把它移动到画面外、调成透明颜色、使用 `if` 或三元表达式判断等方式。示例代码如下: + +```tsx +// ❌ 差的写法,使用条件表达式切换元素显示与否 +{ + !hidden.value && ; +} +// ✅ 好的写法,使用 hidden 属性 +; +``` + +## 后续计划 + +我们后续计划推出渲染树调试工具,届时可以更加细致方便地查看渲染树的渲染情况以及性能问题。 diff --git a/docs/guide/ui-system.md b/docs/guide/ui-system.md index e69de29..a16b912 100644 --- a/docs/guide/ui-system.md +++ b/docs/guide/ui-system.md @@ -0,0 +1,243 @@ +# UI 系统 + +本节将会讲解 2.B 的渲染树与 UI 系统的工作原理,以及一些常用 API。 + +## 创建一个自己的 UI 管理器 + +样板提供 `UIController` 类,允许你在自己的一个 UI 中创建自己的 UI 管理器,例如在样板中,游戏画面本身包含一个 UI 管理器,分为了封面、加载界面、游戏界面三种,其中游戏界面里面还有一个游戏 UI 管理器,我们常用的就是最后一个游戏 UI 管理器。 + +### 创建 UIController 实例 + +我们从 `@motajs/system-ui` 引入 `UIController` 类,然后对其实例化: + +```ts +import { UIController } from '@motajs/system-ui'; + +// 传入一个字符串来表示这个控制器的 id +export const myController = new UIController('my-controller'); +``` + +### 获取 UI 控制器 + +可以通过 id 来获取到这个控制器,或者直接引入对应文件中的控制器: + +```ts +import { UIController } from '@motajs/system-ui'; +import { myController } from './myController'; + +const myController = UIController.get('my-controller'); +``` + +### 添加到渲染树 + +接下来,可以直接调用 `myController.render` 方法来添加到你自己的 UI 中: + +```tsx +{myController.render()} +``` + +## UI 显示模式 + +### 内置显示模式 + +UI 管理器内置了两种显示模式,只显示最后一个以及显示所有。其中前者常用于级联式 UI,例如 `设置 -> 系统设置 -> 快捷键设置`,这时候只会显示最后一个 UI,前面的 UI 不会显示。后者常用于展示信息类的 UI,例如在地图上展示怪物信息等。我们可以通过下面这两个方法来设置 UI 显示模式,立即生效,但不推荐频繁切换,建议一个控制器只使用**一种**显示模式: + +```ts +// 设置为只显示最后一个 +myController.lastOnly(); +// 设置为显示所有 +myController.showAll(); +``` + +### 栈模式 + +对于级联式 UI,我们希望在关闭一个 UI 时,在其之后的 UI 也能关闭,例如对于上面提到的 `设置 -> 系统设置 -> 快捷键设置` 级联 UI,当我们关闭设置界面时,我们会希望系统设置和快捷键设置也一并关闭,而不是需要手动关闭。这时候,栈模式就可以做到这一点,启用栈模式时,关闭一个 UI 后,在其之后的 UI 也会全部关闭。我们依然可以使用上面两个方法来设置是否启用栈模式: + +```ts +// 设置为显示最后一个,启用栈模式,不过 lastOnly 默认启用栈模式,因此参数可不填 +myController.lastOnly(true); +// 设置为显示最后一个,不启用栈模式 +myController.lastOnly(false); +``` + +### 自定义显示模式 + +::: info +这一小节内容不重要,没有特殊需求的可以不看。 +::: + +样板内置的两个显示模式以及栈模式已经能够满足绝大多数情况,不过可能还会有一些非常特殊的情况满足不了,这时候我们可以使用 `showCustom` 方法来自定义一个显示模式。这个方法要求传入一个参数,参数需要是 `IUICustomConfig` 对象,对象要求实现 `open` `close` `hide` `show` `update` 五个方法,我们来介绍一下如何做出一个自定义显示模式。 + +方法说明如下: + +- `open` 方法会在一个 UI 打开时调用,例如默认的 `lastOnly` 模式其实就是在打开 UI 时将 UI 添加至栈末尾,然后隐藏在其之前的所有 UI +- `close` 方法会在一个 UI 关闭时调用,例如默认的 `lastOnly` 模式就会在这个时候把在传入 UI 之后的所有 UI 一并关闭 +- `hide` 方法会在一个 UI 隐藏时调用,默认的 `lastOnly` 模式会在这个时候把 UI 隐藏显示 +- `show` 方法会在一个 UI 显示时调用,默认的 `lastOnly` 模式会在这个时候把 UI 启用显示 +- `update` 方法会在切换显示模式时调用,默认的 `lastOnly` 模式会在这个时候把最后一个 UI 显示,之前的隐藏 + +那么,假如我们要做一个反向 `lastOnly`,即只显示第一个,添加 UI 时添加至队列开头,我们可以这么写: + +```ts +import { IUICustomConfig, IUIInstance } from '@motajs/system-ui'; + +const myCustomMode: IUICustomConfig = { + open(ins: IUIInstance, stack: IUIInstance[]) { + stack.forEach(v => v.hide()); // 隐藏当前所有 UI + stack.unshift(ins); // 将要打开的 UI 添加至队列开头 + ins.show(); // 显示要打开的 UI + }, + close(ins: IUIInstance, stack: IUIInstance[], index: number) { + stack.splice(0, index + 1); // 关闭传入 UI 及其之前的所有内容 + stack[0]?.show(); // 显示第一个 UI + }, + hide(ins: IUIInstance, stack: IUIInstance[], index: number) { + ins.hide(); // 直接隐藏 + }, + show(ins: IUIInstance, stack: IUIInstance[], index: number) { + ins.show(); // 直接显示 + }, + update(stack: IUIInstance[]) { + stack.forEach(v => v.hide()); // 先隐藏所有 UI + stack[0]?.show(); // 然后显示第一个 UI + } +}; + +myController.showCustom(myCustomMode); // 应用自己的显示模式 +``` + +## 设置 UI 背景 + +我们可以为 UI 设置背景组件,背景组件在 UI 打开时常亮。我们推荐使用此方法来为 UI 设置背景,因为它可以搭配 `keep` 防抖动来使用,避免出现 UI 闪烁的问题。现在,我们使用样板内置的 `Background` 背景组件作为例子,来展示如何设置背景: + +```ts +import { Background } from '@user/client-modules'; + +// 传入背景组件作为背景,然后设置参数,使用 winskin.png 作为背景 +myController.setBackground(Background, { winskin: 'winskin.png' }); +``` + +默认情况下,当我们打开 UI 时,背景组件将会自动展示,不过我们也可以手动控制背景组件是否显示,它的优先级高于系统优先级: + +```ts +myController.hideBackground(); // 隐藏背景组件,即使有 UI 已经打开,也不会显示背景 +myController.showBackground(); // 显示背景组件,在 UI 已经打开的情况下展示,没有 UI 打开时不显示 +``` + +## 背景维持防抖动 + +有时候,我们需要关闭当前 UI 然后立刻打开下一个 UI,例如使用一个道具时可能会打开一个新的页面,这时候会先关闭道具背包界面,再打开道具的页面,这时候可能会出现短暂的“背景丢失”,这是因为 UI 的挂载需要时间,在极短的时间内如果没有挂载上,那么就会在屏幕上什么都不显示,上面设置的背景 UI 也不会显示,会引起一次闪烁,观感很差。为了解决这个问题,我们提供了背景维持防抖动的功能,使用 `keep` 方法来实现: + +```ts +const keep = myController.keep(); +``` + +调用此方法后,在下一次 UI 全部关闭时,背景会暂时维持,直到有 UI 打开,也就是说它会维持一次 UI 背景不会关闭,下一次就失效了。这样的话,如果我们去使用一个打开页面的道具,就不会出现闪烁的问题了。不过,假如我们使用了一个没有打开页面的道具,会有什么表现?答案是背景一直显示着,用户就什么也干不了了,这显然不是我们希望的,因此 `keep` 函数的返回值提供了一些能力来让你关闭背景,它们包括: + +```ts +// 推荐方法,使用 safelyUnload 安全地卸载背景,这样如果有 UI 已经打开,不会将其关闭 +keep.safelyUnload(); +// 不推荐方法,调用后立刻关闭所有 UI,不常用 +keep.unload(); +``` + +## 打开与关闭 UI + +在 UI 编写章节已经提到了打开和关闭 UI 使用 `open` 和 `close` 方法,现在我们更细致地讲解一下如何打开与关闭 UI。打开 UI 使用 `open` 方法,定义如下: + +```ts +function open( + ui: IGameUI, + props: UIProps, + alwaysShow?: boolean +): IUIInstance; +``` + +其中第一个参数表示要打开的 UI,第二个表示传给 UI 的参数,第三个表示 UI 是否永远保持显示状态(除非被关闭),不受到显示模式的影响。同种 UI 可以打开多个,也可以在不同的控制器上同时打开多个相同的 UI。例如,如果我们想在主 UI 控制器中添加一个常量的返回游戏按钮,就可以这么写: + +```ts +// BackToGame 是自定义 UI,第三个参数传 true 来保证它一直显示在画面上 +myController.open(BackToGame, {}, true); +``` + +关闭 UI 使用 `close` 方法,传入 UI 实例,即 `open` 方法的返回值,没有其他参数。例如: + +```ts +const MyUI = defineComponent(props => { + // 所有通过 UI 控制器打开的,同时按照 UI 模板填写了 props 的 UI 都包含 controller 和 instance 属性 + props.controller.close(props.instance); +}, myUIProps); +``` + +除此之外,还提供了一个关闭所有 UI 的: + +```ts +function closeAll(ui?: IGameUI): void; +``` + +其中参数表示要关闭的 UI 类型,不填时表示关闭所有 UI,填写时表示关闭所有指定类型的 UI。例如我想关闭所有 `EnemyInfo` UI,可以这么写: + +```ts +// EnemyInfo 是自定义 UI +myController.closeAll(EnemyInfo); +``` + +## 渲染系统的树结构 + +接下来我们来讲解一下渲染系统的一些工作原理。下面的部分由 `DeepSeek R1` 模型生成并稍作修改。 + +### 结构原理 + +想象一棵倒着生长的树: + +- 根节点:相当于画布本身,是所有元素的起点 +- 枝干节点:类似文件夹,可以包含其他元素 +- 叶子节点:实际显示的内容,如图片、文字等 + +### 运作特点 + +- 层级管理:子元素永远在父元素的"内部"显示 +- 自动排序:像叠扑克牌一样,后添加的元素默认盖在之前元素上方,不过也可以通过参数来调整顺序 +- 智能裁剪:父元素就像相框,超出范围的内容自动隐藏 + +## 渲染系统的事件系统 + +### 事件传递三阶段 + +1. 收件扫描(捕获阶段):从根部开始层层扫描,寻找可能接收事件的元素,类似快递分拣中心扫描包裹目的地 +2. 精准投递(目标阶段):找到实际触发事件的元素进行处理,就像快递员将包裹送到收件人手中 +3. 回执确认(冒泡阶段):处理结果沿着原路返回汇报,如同收件人签收后系统更新物流状态 + +将事件分为三个阶段,是为了让交互更加符合直觉,你也不想点击内层按钮的时候外层按钮也被触发吧) + +### 特殊处理机制 + +- 紧急拦截:任何环节都可以标记"无需继续传递" +- 批量处理:多个事件自动合并减少处理次数 +- 智能过滤:自动忽略不可见区域的事件 + +## 冒泡更新 + +### 工作原理 + +当某个元素发生变化时:自动通知直系父元素,父元素检查自身是否需要调整,继续向上传递直到根部,最终统一计算所有需要改变的位置,并在下一帧执行更新。 + +### 设计优势 + +- 精准定位:只更新受影响的部分画面 +- 避免重复:多个子元素变化只需一次整体计算 +- 顺序保障:始终从最深层开始逐层处理 + +## 懒更新机制 + +### 工作模式 + +1. 收集阶段:记录所有需要改变的内容(如颜色变化、文字修改) +2. 等待时机:一般是等待到下一帧 +3. 批量处理:一次性完成所有修改 + +### 实际效益 + +- 性能优化:减少像频繁开关灯的资源浪费 +- 流畅保障:避免连续小改动导致的画面闪烁 +- 智能调度:优先处理用户可见区域的变化 diff --git a/docs/guide/ui.md b/docs/guide/ui.md index e69de29..669c9b6 100644 --- a/docs/guide/ui.md +++ b/docs/guide/ui.md @@ -0,0 +1,850 @@ +--- +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 模板,以怪物手册为例,模板如下,直接复制粘贴即可: + +```tsx +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; + +export const MyBook = defineComponent(props => { + return () => ; +}, myBookProps); + +export const MyBookUI = new GameUI('my-book', MyBook); +``` + +然后打开 `index.ts`,增加如下代码: + +```ts +export * from './myUI'; +``` + +## 添加一些内容 + +新的 UI 使用 tsx 编写,即 `TypeScript JSX`,可以直接在 ts 文件中编写 XML,非常适合编写 UI。例如,我们想要把 UI 的位置设为水平竖直居中,位置在 240, 240,长宽为 480, 480,并显示一个文字,可以这么写: + +```tsx +// ... 其他内容 +// loc 参数表示这个元素的位置,六个数分别表示: +// 横纵坐标;长宽;水平竖直锚点,0.5 表示居中,1 表示靠右或靠下对齐,可以填不在 0-1 范围的数 +// 每两项组成一组,这两项要么都填,要么都不填,例如长宽可以都不填,横纵坐标可以都不填 +// 不填时会使用默认值,或是组件内部计算出的值 +return () => ( + + {/* 文字元素会自动计算长宽,因此不能手动指定 */} + + +); +``` + +## 显示 UI + +我们编写完 UI 之后,这个 UI 并不会自己显示,需要手动打开。我们找到 `ui/main.tsx`,在 `MainScene` 这个根组件中添加一句话: + +```ts +// 在这添加引入 +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 的状态: + +```tsx +export const MyBook = defineComponent(props => { + // 例如,我们可以让它在打开 10 秒钟后关闭: + setTimeout(() => props.controller.close(props.instance), 10000); + return () => ( + // ... UI 内容 + ); +}, myBookProps); +``` + +除此之外,我们还可以在任意渲染端模块中引入 `ui/controller` 来获取到根组件的 UI 控制器,注意跨文件夹引入时需要引入 `@user/client-modules`。例如,我们可以在其他文件中控制这个 UI 的开启与关闭: + +```ts +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` 引入: + +```ts +const { mainUIController } = Mota.require('@user/client-modules'); +``` + +也可以通过 `UIController` 的接口获取其实例: + +```ts +import { UIController } from '@motajs/system-ui'; + +const mainUIController = UIController.getController('main-ui'); +``` + +更多的 UI 控制功能可以参考后续文档以及相关的 [UI 系统指南](./ui-system.md) 或 [API 文档](../api/motajs-system-ui/UIController)。 + +## 添加更多内容 + +既然我们要编写一个简易怪物手册,那么仅靠上面这些内容当然不够,我们需要更多的元素和组件才行,下面我们来介绍一些常用的元素及组件。 + +### 图标 + +既然是怪物手册,那么图标必然不能少,图标是 `` 元素,需要传入 `icon` 参数,例如: + +```tsx +return () => ( + + {/* 显示绿史莱姆图标,位置在 (32, 32),循环播放动画 */} + + +); +``` + +### 字体 + +我们很多时候也会想要自定义字体,可以通过 `Font` 类来实现这个功能: + +```tsx +import { Font, FontWeight } from '@motajs/render'; + +// 创建一个字体,包含五个参数,第一个是字体名称,第二个是字体大小,第三个是字体大小的单位,一般是 'px' +// 第四个是字体粗细,默认是 400,可以填 FontWeight.Bold,FontWeight.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 () => ( + + + + +); +``` + +更多的字体使用方法可以参考 [API 文档](../api/motajs-render-style/Font) + +### 圆角矩形 + +我们可以为怪物手册的一栏添加圆角矩形,写法如下: + +```tsx +return () => ( + + + +); +``` + +### 线段 + +我们也可以添加线段,作为怪物列表之间的分割线: + +```tsx +return () => ( + + + +); +``` + +### winskin 背景 + +我们可以为手册添加一个 winskin 背景,可以使用 `Background` 组件: + +```tsx +// 从 components 文件夹中引入这个组件 +import { Background } from '../components'; + +return () => ( + + + +); +``` + +### 滚动条 + +怪物多的话一页肯定显示不完,因此我们可以添加一个滚动条 `Scroll` 组件,用法如下: + +```tsx +// 从 components 文件夹中引入这个组件 +import { Scroll } from '../components'; + +return () => ( + // 使用滚动条组件替换 container 元素 + // [!code ++] + + {/* 其他内容 */} + // [!code ++] +); +``` + +在使用滚动条时,建议使用平铺式布局,将每个独立的内容平铺显示,而不是整体包裹为一个 `container`,这有助于提高性能表现。 + +### 循环 + +编写怪物手册的话,我们就必须用到循环,因为我们需要遍历当前怪物列表,然后每个怪物生成一个 `container`,在这个 `container` 里面显示内容。tsx 为我们提供了嵌入表达式的功能,因此我们可以通过 `map` 方法来遍历怪物列表,然后返回一个元素,组成元素数组,实现循环遍历的功能。示例如下: + +```tsx +export const MyBook = defineComponent(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 () => ( + + {/* 写一个 map 循环,将一个容器元素返回,就可以显示了 */} + {enemys.map((v, i) => { + return ( + + {/* 怪物图标与怪物名称 */} + + + {/* 显示怪物的属性 */} + + + {/* 其他的属性,例如攻击,防御等 */} + + ); + })} + + ); +}, myBookProps); +``` + +### 条件判断 + +可以在表达式中使用三元表达式或者立即执行函数来实现条件判断: + +```tsx +return () => ( + + {enemys.length === 0 ? ( + // 无怪物时,显示没有剩余怪物 + // [!code ++] + ) : ( + enemys.map(v => { + // 有怪物时 + }) + )} + +); +``` + +## 响应式 + +使用新的 UI 系统时,最大的优势就是响应式了,它可以让 UI 在数据发生变动时自动更改显示内容,而不需要手动重绘。本 UI 系统完全兼容 `vue` 的响应式系统,非常方便。 + +### 基础用法 + +例如,我想要给我的怪物手册添加一个楼层 id 的参数,首先我们先定义这个参数: + +```tsx +import { computed } from 'vue'; + +export interface MyBookProps extends UIComponentProps { + // 定义 floorId 参数 + floorId: FloorIds; +} + +const myBookProps = { + // 这里也要修改 + props: ['controller', 'instance', 'floorId'] +} satisfies SetupComponentOptions; +``` + +然后我们需要在这个参数发生变动时修改怪物列表,可以这么写: + +```tsx +export const MyBook = defineComponent(props => { + // 使用 computed,这样的话就会自动追踪到 props.floorId 参数,更新怪物列表,并更新显示内容 + const enemys = computed(() => core.getCurrentEnemys(props.floorId)); // [!code ++] + + return () => ( + + {/* 需要使用 enemys.value 属性,不能直接使用 enemys.length */} + {enemys.value.length === 0 ? ( // [!code ++] + + ) : ( + // 同上,需要 value 属性 + enemys.value.map(v => {}) // [!code ++] + )} + + ); +}, myBookProps); +``` + +### 什么样的变量能使用响应式 + +其实,我们用一般的方式编写的变量或常量都是不能使用响应式的,例如这些都不行: + +```ts +let num = 10; +let str = '123'; + +const num2 = computed(() => num * 2); +const str2 = computed(() => parseInt(str)); +``` + +这么写的话,是没有响应式效果的,这是因为 `num` 和 `str` 并不是响应式变量,不能追踪到。对于 `string` `number` `boolean` 这些字面量类型的变量,我们需要使用 `ref` 函数包裹才可以: + +```tsx +import { ref } from 'vue'; + +// 使用 ref 函数包裹 +const num = ref(10); +// 使用 num.value 属性调用 +const num2 = computed(() => num.value * 2); +// 使用 num.value 修改值 +num.value = 20; + +// 这样的话就有响应式效果了 +; +``` + +对于对象类型来说,需要使用 `reactive` 函数包裹,这个函数会把对象变成深层响应式,任何一级发生更改都会触发响应式更新,例如: + +```tsx +const obj = reactive({ obj1: { num: 10 } }); + +// 这个就不需要使用 value 属性了,只有 ref 函数包裹的需要 +obj.obj1.num = 20; + +// 直接调用即可,当值更改时内容也会自动更新 +; +``` + +数组也可以使用 `reactive` 方法来实现响应式: + +```tsx +// 传入一个泛型来指定这个变量的类型,这里使用数字数组作为示例 +const array = reactive([]); + +// 可以使用数组自身的方法添加或修改元素 +array.push(100); + + + {/* 直接对数组遍历,数组修改后这段内容也会自动更新 */} + {array.map(v => ( + + ))} +; +``` + +如果对象比较大,只想让第一层变为响应式,深层的不变,可以使用 `shallowReactive` 或 `shallowRef`,或使用 `markRaw` 手动标记不需要响应式的部分: + +```ts +// 这样的话,当 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 + +```ts [ref] +const num1 = ref(10); +const num2 = ref(20); + +watch(num1, (newValue, oldValue) => { + // 当 num1 的值发生变化时,在控制台输出新值和旧值 + console.log(newValue, oldValue); + + // 这里就不是组件顶层,不要使用 watch。如果需要条件判断的话,可以在监听函数内部判断,而不是外部 + watch(num2, () => {}); +}); +``` + +```ts [reactive] +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 官方文档](https://cn.vuejs.org/) + +## 鼠标与触摸交互事件 + +### 监听鼠标或触摸 + +通过上面这些内容,我们已经可以搭出来一个完整的怪物手册页面了,不过现在这个页面是死的,还没办法交互,我们需要让它有办法交互,允许用户点击和按键操作。UI 系统提供了丰富方便的接口来实现交互动作的监听,例如监听点击可以使用 `onClick`: + +```tsx +const click = () => { + console.log('clicked!'); +}; + +// 直接将函数传入 onClick 属性即可 +{/* 渲染内容 */}; +``` + +可以使用 `cursor` 属性来指定鼠标移动到该元素上时的指针样式,如下例所示,鼠标移动到这个容器上时就会变成小手的形状: + +```tsx + +``` + +鼠标与触摸事件的触发包括两个阶段,从根节点捕获,然后一路传递到最下层,然后从最下层冒泡,然后一路再传递回根节点,一般情况下我们使用冒泡阶段的监听即可,也就是 `onXxx`,例如 `onClick` 等,不过如果我们需要监听捕获阶段的事件,也可以使用 `onXxxCapture` 的方法来监听: + +```tsx +const clickCapture = () => { + console.log('click capture.'); +}; +const click = () => { + console.log('click bubble.'); +}; + +; +``` + +当点击这个容器时,就会先触发 `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` 方法即可阻止冒泡或捕获的继续传播: + +```tsx +import { IActionEvent } from '@motajs/render'; + +const click1 = (e: IActionEvent) => { + // 调用以阻止冒泡的继续传播 + e.stopPropagation(); + console.log('click1'); +}; +const click2 = () => { + console.log('click2'); +}; + + + +; +``` + +在上面这个例子中,当我们点击内层的容器时,只会触发 `click1`,而不会触发 `click2`,只有当我们点击外层容器时,才会触发 `click2`,这样就成功避免了内外两个按钮同时触发的场景。 + +### 事件对象的属性 + +事件包含很多属性,它们定义如下,其中 `IActionEventBase` 是 `enter` `leave` 的事件对象,`IActionEvent` 是按下、抬起、移动、点击的事件对象,`IWheelEvent` 是滚轮的事件对象。 + +::: code-group + +```ts [IActionEventBase] +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; +} +``` + +```ts [IActionEvent] +export interface IActionEvent extends IActionEventBase { + /** 这次操作的标识符,在按下、移动、抬起阶段中保持不变 */ + identifier: number; + /** 相对于触发元素左上角的横坐标 */ + offsetX: number; + /** 相对于触发元素左上角的纵坐标 */ + offsetY: number; + /** 相对于整个画布左上角的横坐标 */ + absoluteX: number; + /** 相对于整个画布左上角的纵坐标 */ + absoluteY: number; + + /** + * 调用后将停止事件的继续传播。 + * 在捕获阶段,将会阻止捕获的进一步进行,在冒泡阶段,将会阻止冒泡的进一步进行。 + * 如果当前元素有很多监听器,该方法并不会阻止其他监听器的执行。 + */ + stopPropagation(): void; +} +``` + +```ts [IWheelEvent] +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` 常量,在模块顶层注册一个按键命令: + +```ts +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` 函数,然后在组件顶层这么使用: + +```tsx +import { useKey } from '@motajs/render'; + +export const MyBook = defineComponent(props => { + // 第一个参数是按键实例,第二个参数是按键作用域,一般用不到 + const [key, scope] = useKey(); + + return () => ; +}); +``` + +最后,实现按键操作,使用 `key.realize` 方法: + +```tsx +import { clamp } from 'lodash-es'; + +export const MyBook = defineComponent(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 () => ; +}); +``` + +## 绘制选择框与动画 + +### 定义选择框动画 + +下面我们来把选择框加上,当按下方向键时,选择框会移动,当按下确定键时,会打开这个怪物的详细信息。首先,我们使用一个描边格式的 `g-rectr` 圆角矩形元素作为选择框: + +```tsx + + + +``` + +接下来,我们需要让它能够移动,当用户按下按键时,选择框会平滑移动到目标位置。这时候,我们可以使用动画接口 `transitioned` 来实现平滑移动。我们需要先用它定义一个动画对象: + +```ts +// 这个函数在用户代码里面,直接引入 +import { transitioned } from '../use'; +// 从高级动画库中引入双曲速率曲线,该曲线视角效果相对较好 +import { hyper } from 'mutate-animate'; + +// 创建一个纵坐标动画对象,初始值为 0(第一个参数),动画时长 150ms(第二个参数) +// 曲线为 慢-快-慢 的双曲正弦曲线(第三个参数) +const rectY = transitioned(0, 150, hyper('sin', 'in-out')); +``` + +然后,我们需要通过 `computed` 方法来动态生成圆角矩形的位置: + +```ts +const rectLoc = computed(() => [ + 16, + // 使用 rectY.ref.value 获取到动画对象的响应式变量 + rectY.ref.value, + 480 - 32, + 480 - 32 +]); +``` + +最后,我们把圆角矩形的 `loc` 属性设为 `computed` 值: + +```tsx + + + +``` + +### 执行动画 + +接下来,我们需要监听当前选中怪物,然后根据当前怪物来设置元素位置,使用 `watch` 监听 `selected` 变量: + +```ts +watch(selected, value => { + // 使用 set 方法来动画至目标值 + rectY.set(16 + value * 80); +}); +``` + +除此之外,我们还可以添加当鼠标移动至怪物元素上时,选择框也移动至目标,我们需要监听 `onEnter` 事件: + +```tsx +const onEnter = (index: number) => { + // 前面已经监听过 selected 了,这里直接设置即可,不需要再调用 rectY.set + // 不过调用了也不会有什么影响,动画会智能处理这种情况 + selected.value = index; +}; + + + {/* 把圆角矩形的纵深调大,防止被怪物容器遮挡 */} + + {enemys.map((v, i) => { + // 元素内容不再展示。监听时,需要传入一个函数,因此需要使用匿名箭头函数包裹, + // 添加 void 关键字是为了防止返回值泄漏,不过在这里并不是必要,因为 onEnter 没有返回值 + return void onEnter(i)}>; + })} +; +``` + +### 处理重叠 + +如果你去尝试着使用上面这个方法来实现动画,并给每个怪物添加了一个点击事件,你会发现你可能无法触发选中怪物的点击事件,这是因为 `g-rectr` 的纵深 `zIndex` 较高,交互事件会传播至此元素,而不会传播至下层元素,于是就不会触发点击事件。样板自然也考虑到了这种情况,我们只需要给圆角矩形添加一个 `noevent` 标识,即可让交互事件不会受到此元素的影响,不过相应地,这个元素上的交互事件也将会无法触发。示例如下: + +```tsx + + + {enemys.map((v, i) => { + return void onEnter(i)}>; + })} + +``` + +## 调用 Scroll 组件接口 + +我们现在已经实现了按键操作,但是移动时并不能同时修改滚动条的位置,这会导致当前选中的怪物跑到画面之外,这时候我们需要自动滚动到目标位置,可以使用 `Scroll` 组件暴露出的接口来实现。我们使用 `ref` 属性来获取其接口: + +```tsx +import { ScrollExpose } from './components'; + +const scrollExpose = ref(); + +; +``` + +然后,我们可以调用其 `scrollTo` 方法来滚动至目标位置: + +```tsx +import { ScrollExpose } from './components'; + +const scrollExpose = ref(); + +watch(selected, () => { + // 滚动到选中怪物上下居中的位置,组件内部会自动处理滚动条边缘,因此不需要担心为负值 + scrollExpose.value.scrollTo(selected.value * 80 - 240); +}); + +; +``` + +## 修改 UI 参数 + +在打开 UI 时,我们可以传入参数,默认情况下,可以传入所有的 `BaseProps`,也就是所有元素通用属性,以及自己定义的 UI 参数。`BaseProps` 内容较多,可以参考 [API 文档](../api/motajs-render-vue/RenderItem.md)。除此之外,我们还为这个自定义怪物手册添加了 `floorId` 参数,它也可以在打开 UI 时传入。如果需要打开的 UI 参数具有响应式,例如可以动态修改楼层 id,可以使用 `reactive` 方法。示例如下: + +```ts +import { MyBookProps, MyBookUI } from './myUI'; + +const props = reactive({ + floorId: 'MT0', + zIndex: 100 +}); + +mainUIController.open(MyBookUI, props); +``` + +我们可以监听状态栏更新来实时更新参数: + +```ts +import { hook } from '@user/data-base'; + +// 监听状态栏更新事件 +hook.on('updateStatusBar', () => { + // 状态栏更新时,修改怪物手册的楼层为当前楼层 id + props.floorId = core.status.floorId, +}); +``` + +## 总结 + +通过以上的学习,你已经可以做出一个自己的怪物手册了!试着做一下吧!
是
否
加载 index.html
加载 2.x 样板的第三方库
是否在游戏中?
加载渲染端入口
加载数据端入口
初始化数据端,写入 Mota 全局变量
dataRegistered
初始化渲染端
clientRegistered
registered
执行数据端各个模块的初始化函数
执行渲染端各个模块的初始化函数
dataRegistered 与 registered
执行 main.js 初始化
加载全塔属性
加载 core.js 及其他 libs 中的脚本
coreInit
开始资源加载
自动元件加载完毕后触发 autotileLoaded
资源加载完毕后触发 loaded
进入标题界面