mirror of
https://github.com/motajs/template.git
synced 2026-11-01 14:14:30 +08:00
init: 2.B
This commit is contained in:
commit
6bf32174d8
610 files changed
+151444
No files matched your search
@@ -0,0 +1,393 @@
|
||||
# 音频系统
|
||||
|
||||
2.B 有了与 2.A 完全不同的音频系统,新的音频系统更加自由,功能更加丰富,可以创建多种自定义效果器。本文将讲解如何使用音频系统。
|
||||
|
||||
:::tip
|
||||
多数情况下,你应该不需要使用本文所介绍的内容,因为样板已经将音效、背景音乐等处理完善。如果你想实现高级效果,例如混响效果等,才需要阅读本文。
|
||||
:::
|
||||
|
||||
## 获取音频播放器
|
||||
|
||||
音频播放器在 `@user/client-modules` 模块中,直接引入即可:
|
||||
|
||||
```ts
|
||||
// 在其他模块中使用模块化语法引入
|
||||
import { audioPlayer } from '@user/client-modules';
|
||||
// 在 client-modules 模块中使用模块化语法引入
|
||||
import { audioPlayer } from '../audio'; // 改为你自己的相对路径
|
||||
|
||||
// 使用 Mota 全局变量引入
|
||||
const { audioPlayer } = Mota.require('@user/client-modules');
|
||||
```
|
||||
|
||||
## 音频系统工作流程
|
||||
|
||||
音频播放流程如下:
|
||||
|
||||
```mermaid
|
||||
graph LR;
|
||||
A(音频源) --> B(效果器) --> C(目的地(扬声器、耳机))
|
||||
```
|
||||
|
||||
## 创建音频源
|
||||
|
||||
:::tip
|
||||
本小节的内容极大概率用不到,如果不是需要非常底层的音频接口,可以不看本小节。
|
||||
:::
|
||||
|
||||
样板内置了几种音频源,它们包括:
|
||||
|
||||
| 类型 | 适用场景 | 创建方法 |
|
||||
| --------------- | ----------------------- | ----------------------- |
|
||||
| `BufferSource` | 预加载的完整音频文件 | `createBufferSource()` |
|
||||
| `ElementSource` | 通过 `<audio>` 标签播放 | `createElementSource()` |
|
||||
| `StreamSource` | 流式音频/长音频 | `createStreamSource()` |
|
||||
|
||||
### `StreamSource` 音频源
|
||||
|
||||
一般情况下,我们推荐使用 `opus` 格式的音频,这时候需要使用 `StreamSource` 音频源来播放。这个音频源包含了对 IOS 的适配,可以正确播放 `opus` 格式的音频。在使用它之前,我们需要先创建一个 `StreamLoader` 类,来对音频流式加载。假如你在 `project/mybgm/` 文件夹下有一个 `xxx.opus` 音频,你可以这么创建:
|
||||
|
||||
```ts
|
||||
import { StreamLoader } from '@user/client-modules';
|
||||
|
||||
const stream = new StreamLoader('project/mybgm/xxx.opus');
|
||||
```
|
||||
|
||||
然后,创建音频源,并将流加载对象泵入音频源:
|
||||
|
||||
```ts
|
||||
const source = audioPlayer.createStreamSource();
|
||||
stream.pipe(source);
|
||||
stream.start(); // 开始流式加载,如果不需要实时性,也可以不调用,音频播放时会自动开始加载
|
||||
```
|
||||
|
||||
### `ElementSource` 音频源
|
||||
|
||||
从一个 `audio` 元素创建音频源,假设你想要播放 `project/mybgm/xxx.mp3`,那么你可以这么创建:
|
||||
|
||||
```ts
|
||||
const source = audioPlayer.createElementSource();
|
||||
source.setSource('project/mybgm/xxx.mp3');
|
||||
```
|
||||
|
||||
### `BufferSource` 音频源
|
||||
|
||||
从音频缓冲创建音频源。音频缓冲是直接存储在内存中的一段原始音频波形数据,不经过任何压缩。假如你想播放 `project/mysound/xxx.wav`,可以这么写:
|
||||
|
||||
```ts
|
||||
async function loadWav(url: string) {
|
||||
// 使用浏览器接口 fetch 来请求文件
|
||||
const response = await fetch(url);
|
||||
// 将文件接收为 ArrayBuffer 形式
|
||||
const buffer = await response.arraybuffer();
|
||||
// 创建音频源
|
||||
const source = audioPlayer.createBufferSource();
|
||||
// 直接传入 ArrayBuffer,内部会自动解析,当然也可以自己解析,传入 AudioBuffer
|
||||
await source.setBuffer(source);
|
||||
// 将音频源返回,供后续使用
|
||||
return source;
|
||||
}
|
||||
```
|
||||
|
||||
## 创建音频路由
|
||||
|
||||
音频路由包含了音频播放的所有流程,想要播放一段音频,必须首先创建音频路由,然后使用 `audioPlayer` 播放这个音频路由。如下例所示:
|
||||
|
||||
```ts
|
||||
import { AudioRoute, audioPlayer } from '@user/client-modules';
|
||||
|
||||
const route = audioPlayer.createRoute(source);
|
||||
```
|
||||
|
||||
下面,我们需要将音频路由添加至音频播放器:
|
||||
|
||||
```ts
|
||||
audioPlayer.addRoute('my-route', route);
|
||||
```
|
||||
|
||||
之后,我们就可以使用 `audioPlayer` 播放这个音频了:
|
||||
|
||||
```ts
|
||||
audioPlayer.play('my-route');
|
||||
```
|
||||
|
||||
## 音频效果器
|
||||
|
||||
新的音频系统中最有用的功能就是音频效果器了。音频效果器允许你对音频进行处理,可以实现调节声道音量、回声效果、延迟效果,以及各种自定义效果等。
|
||||
|
||||
内置效果器包含这些:
|
||||
|
||||
| 效果器类型 | 功能说明 | 创建方法 |
|
||||
| --------------------- | ------------------ | --------------------------- |
|
||||
| `VolumeEffect` | 音量控制 | `createVolumeEffect()` |
|
||||
| `StereoEffect` | 立体声场调节 | `createStereoEffect()` |
|
||||
| `EchoEffect` | 回声效果 | `createEchoEffect()` |
|
||||
| `DelayEffect` | 延迟效果 | `createDelay()` |
|
||||
| `ChannelVolumeEffect` | 调节某个声道的音量 | `createChannelVolumeEffect` |
|
||||
|
||||
每个效果器都有自己可以调节的属性,具体可以查看对应效果器的 API 文档,比较简单,这里不在讲解。下面主要讲解一下如何使用效果器,我们直接通过例子来看(代码由 `DeepSeek R1` 模型生成并微调):
|
||||
|
||||
```ts
|
||||
// 创建效果链
|
||||
const volume = audioPlayer.createVolumeEffect();
|
||||
const echo = audioPlayer.createEchoEffect();
|
||||
|
||||
// 配置效果参数
|
||||
volume.setGain(0.7); // 振幅变为 0.7 倍
|
||||
echo.setEchoDelay(0.3); // 回声延迟 0.3 秒
|
||||
echo.setFeedbackGain(0.5); // 回声增益为 0.5
|
||||
|
||||
// 应用效果到音频路由
|
||||
const route = audioPlayer.getRoute('my-route')!;
|
||||
route.addEffect([volume, echo]);
|
||||
|
||||
// 之后播放就有效果了
|
||||
route.play();
|
||||
```
|
||||
|
||||
## 空间音效
|
||||
|
||||
本音频系统还支持空间音效,可以设置听者位置和音频位置,示例如下(代码由 `DeepSeek R1` 模型生成并微调):
|
||||
|
||||
```ts
|
||||
// 设置听者位置
|
||||
audioPlayer.setListenerPosition(0, 1.7, 0); // 1.7米高度
|
||||
audioPlayer.setListenerOrientation(0, 0, -1); // 面朝屏幕内
|
||||
|
||||
// 设置声源位置(使用 StereoEffect 效果器)
|
||||
const stereo = audioPlayer.createStereoEffect();
|
||||
stereo.setPosition(5, 0, -2); // 右方5米,地面下方2米
|
||||
```
|
||||
|
||||
## 淡入淡出效果
|
||||
|
||||
音频系统提供了淡入淡出接口,可以搭配 `mutate-animate` 库实现淡入淡出效果:
|
||||
|
||||
```ts
|
||||
import { Transition, linear } from 'mutate-animate';
|
||||
|
||||
// 创建音量效果器
|
||||
const volume = audioPlayer.createVolumeEffect();
|
||||
|
||||
// 创建渐变类,使用渐变是因为可以避免来回播放暂停时的音量突变
|
||||
const trans = new Transition();
|
||||
trans.value.volume = 0;
|
||||
|
||||
// 每帧设置音量
|
||||
trans.ticker.add(() => {
|
||||
volume.setVolume(trans.value.volume);
|
||||
});
|
||||
|
||||
// 当音频播放时执行淡入
|
||||
route.onStart(() => {
|
||||
// 两秒钟时间线性淡入
|
||||
trans.time(2000).mode(linear()).transition('volume', 1);
|
||||
});
|
||||
route.onEnd(() => {
|
||||
// 三秒钟时间线性淡出
|
||||
trans.time(3000).mode(linear()).transition('volume', 0);
|
||||
});
|
||||
|
||||
// 添加音量效果器
|
||||
route.addEffect(volume);
|
||||
```
|
||||
|
||||
## 音效系统
|
||||
|
||||
为了方便播放音效,音频系统内置提供了音效的播放器,允许你播放空间音效。
|
||||
|
||||
### 播放音效
|
||||
|
||||
样板已经自动将所有注册的音效加入到音效系统中,你只需要直接播放即可,不需要手动加载。播放时,可以指定音频的播放位置,听者(玩家)位置可以通过 `audioPlayer.setPosition` 及 `audioPlayer.setOrientation` 设置。示例如下:
|
||||
|
||||
```ts
|
||||
import { soundPlayer } from '@user/client-modules';
|
||||
|
||||
// 播放已加载的音效
|
||||
const soundId = soundPlayer.play(
|
||||
'mysound.opus',
|
||||
[1, 0, 0], // 音源位置,在听者前方 1m 处
|
||||
[0, 1, 0] // 音源朝向,朝向天花板
|
||||
);
|
||||
|
||||
// 停止指定音效
|
||||
soundPlayer.stop(soundId);
|
||||
// 停止所有音效
|
||||
soundPlayer.stopAllSounds();
|
||||
```
|
||||
|
||||
### 设置是否启用音效
|
||||
|
||||
你可以自行设置是否启用音效系统:
|
||||
|
||||
```ts
|
||||
soundPlayer.setEnabled(false); // 关闭音效系统
|
||||
soundPlayer.setEnabled(true); // 启用音效系统
|
||||
```
|
||||
|
||||
## 音乐系统
|
||||
|
||||
音乐系统的使用与音频系统类似,包含播放、暂停、继续等功能。示例如下:
|
||||
|
||||
```ts
|
||||
import { bgmController } from '@user/client-modules';
|
||||
|
||||
bgmController.play('bgm1.opus'); // 切换到目标音乐
|
||||
bgmController.pause(); // 暂停当前音乐,会有渐变效果
|
||||
bgmController.resume(); // 继续当前音乐,会有渐变效果
|
||||
|
||||
bgmController.blockChange(); // 禁用音乐切换,之后调用 play, pause, resume 将没有效果
|
||||
bgmController.unblockChange(); // 启用音乐切换
|
||||
```
|
||||
|
||||
## 自定义效果器
|
||||
|
||||
本小节内容由 `DeepSeek R1` 模型生成并微调。
|
||||
|
||||
效果器是新的音频系统最强大的功能,而且此系统也允许你自定义一些效果器,实现自定义效果。效果器的工作流程如下:
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
Input[输入源] --> EffectInput[效果器输入]
|
||||
EffectInput --> Processing[处理节点]
|
||||
Processing --> EffectOutput[效果器输出]
|
||||
EffectOutput --> NextEffect[下一效果器]
|
||||
```
|
||||
|
||||
:::info
|
||||
这一节难度较大,如果你不需要复杂的音效效果,不需要看这一节。
|
||||
:::
|
||||
|
||||
### 创建效果器类
|
||||
|
||||
所有效果器都需要继承 `AudioEffect` 抽象类,需要实现这些内容:
|
||||
|
||||
```ts
|
||||
abstract class AudioEffect implements IAudioInput, IAudioOutput {
|
||||
abstract output: AudioNode; // 输出节点
|
||||
abstract input: AudioNode; // 输入节点
|
||||
abstract start(): void; // 效果激活时调用
|
||||
abstract end(): void; // 效果结束时调用
|
||||
}
|
||||
```
|
||||
|
||||
### 实现效果器
|
||||
|
||||
下面以一个双线性低通滤波器为例,展示如何创建一个自定义滤波器。首先,我们需要继承 `AudioEffect` 抽象类:
|
||||
|
||||
```ts
|
||||
class CustomEffect extends AudioEffect {
|
||||
// 实现抽象成员
|
||||
output: AudioNode;
|
||||
input: AudioNode;
|
||||
}
|
||||
```
|
||||
|
||||
接下来,我们需要构建音频节点,创建一个 `BiquadFilter`:
|
||||
|
||||
```ts
|
||||
class CustomEffect extends AudioEffect {
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
|
||||
// 创建处理节点链
|
||||
const filter = ac.createBiquadFilter(); // 滤波器节点
|
||||
filter.type = 'lowpass'; // 低通滤波器
|
||||
// 输入节点和输出节点都是滤波器节点
|
||||
this.input = filter;
|
||||
this.output = filter;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
然后,我们可以提供接口来让外部能够调整这个效果器的参数:
|
||||
|
||||
```ts
|
||||
class CustomEffect extends AudioEffect {
|
||||
private Q: number = 1;
|
||||
private frequency: number = 1000;
|
||||
|
||||
/** 设置截止频率 */
|
||||
setCutoff(freq: number) {
|
||||
this.frequency = Math.min(20000, Math.max(20, freq));
|
||||
this.output.frequency.value = this.frequency;
|
||||
}
|
||||
|
||||
/** 设置共振系数 */
|
||||
setResonance(q: number) {
|
||||
this.Q = Math.min(10, Math.max(0.1, q));
|
||||
this.output.Q.value = this.Q;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
最后,别忘了实现 `start` 方法和 `end` 方法,虽然不需要有任何内容:
|
||||
|
||||
```ts
|
||||
class CustomEffect extends AudioEffect {
|
||||
start() {}
|
||||
end() {}
|
||||
}
|
||||
```
|
||||
|
||||
### 使用效果器
|
||||
|
||||
就如内置的效果器一样,创建效果器实例并添加入路由图即可:
|
||||
|
||||
```ts
|
||||
const myEffect = new CustomEffect(audioPlayer.ac);
|
||||
myRoute.addEffect(myEffect);
|
||||
```
|
||||
|
||||
### 高级技巧
|
||||
|
||||
动画修改属性:
|
||||
|
||||
```ts
|
||||
// 创建参数渐变
|
||||
rampFrequency(target: number, duration: number) {
|
||||
const current = this.output.frequency.value;
|
||||
this.output.frequency.setValueAtTime(current, this.ac.currentTime);
|
||||
this.output.frequency.linearRampToValueAtTime(
|
||||
target,
|
||||
this.ac.currentTime + duration
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
在一个效果器内添加多个音频节点:
|
||||
|
||||
```ts
|
||||
class ReverbEffect extends AudioEffect {
|
||||
private convolver: ConvolverNode;
|
||||
private wetGain: GainNode;
|
||||
|
||||
constructor(ac: AudioContext) {
|
||||
super(ac);
|
||||
this.input = ac.createGain(); // 输入增益节点
|
||||
this.wetGain = ac.createGain(); // 卷积增益节点
|
||||
this.convolver = ac.createConvolver(); // 卷积节点
|
||||
|
||||
// 构建混合电路
|
||||
const dryGain = ac.createGain(); // 原始音频增益节点
|
||||
this.input.connect(dryGain);
|
||||
this.input.connect(this.convolver);
|
||||
this.convolver.connect(this.wetGain);
|
||||
|
||||
// 合并输出
|
||||
const merger = ac.createChannelMerger();
|
||||
dryGain.connect(merger, 0, 0);
|
||||
this.wetGain.connect(merger, 0, 1);
|
||||
this.output = merger;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
以上效果器的流程图如下:
|
||||
|
||||
```mermaid
|
||||
graph LR;
|
||||
A(input 增益节点) --> B(dryGain 增益节点);
|
||||
A --> C(convolver 卷积节点) --> D(wetGain 增益节点)
|
||||
B & D --> E(output 声道合并节点)
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# 代码编写指南
|
||||
|
||||
本节将介绍如何在 2.B 样板中正确编写代码
|
||||
|
||||
## 在哪编写代码?
|
||||
|
||||
我们推荐在 `packages-user` 中编写代码,在这里你可以享受到完整的类型支持与代码补全。展开 `packages-user` 文件夹,可以看到里面分为了多个文件夹。我们应该主要在 `client-modules` 和 `data-state` 中编写代码,分别是渲染层和数据层的主要代码位置。其余内容一般是系统底层相关的代码,如果看不懂的话不建议修改。
|
||||
|
||||
```mermaid
|
||||
graph LR
|
||||
root(package-user)
|
||||
root --> A(client-modules) --> A1(渲染层主要代码)
|
||||
root --> B(data-base) --> B1(数据层底层代码)
|
||||
root --> C(data-fallback) --> C1(数据层向后兼容代码)
|
||||
root --> D(data-state) --> D1(数据层游戏状态代码)
|
||||
root --> E(data-utils) --> E1(数据层工具代码)
|
||||
root --> F(entry-client) --> F1(渲染层入口代码)
|
||||
root --> G(entry-data) --> G1(数据层入口代码)
|
||||
root --> H(legacy-plugin-client) --> H1(遗留渲染层代码)
|
||||
root --> I(legacy-plugin-data) --> I1(遗留数据层代码)
|
||||
```
|
||||
|
||||
## 如何使用模块化引入?
|
||||
|
||||
在引入时,需要**严格**遵循如下原则:
|
||||
|
||||
1. 同一模块间使用相对路径引入 `./xxx`
|
||||
2. 不同模块间使用绝对路径引入 `@user/xxxx`
|
||||
|
||||
例如,如果你在 `@user/client-modules` 中引入自身的代码,就需要使用 `import { xxx } from './xxx`,如果你需要在 `@user/client-modules` 引入 `@user/data-base` 中的代码,就需要使用 `import { xxx } from '@user/data-base'` 引入。
|
||||
|
||||
:::warning
|
||||
注意,在之后的所有文档示例中,都会使用 `import xxx from '@xxx/xxx'` 的绝对路径形式作为示例,而不会使用相对路径,自己编写代码时请注意要引入的内容是否在当前模块(当前包)中,如果是,请使用相对路径,否则请使用绝对路径。
|
||||
:::
|
||||
|
||||
## 使用 TypeScript
|
||||
|
||||
所有代码使用 `TypeScript` 编写,后缀名为 `.ts` 或 `.tsx`,其中前者表示一般代码,后者表示 UI 代码(即包含 XML 的代码)
|
||||
|
||||
编写代码时,需要保证类型正确。如果搞不明白类型系统,类型就写 `any`(但是极其不推荐!这会使得自动补全也消失!)
|
||||
|
||||
`TypeScript` 类型系统教程可以查看[我编写的教程](https://h5mota.com/bbs/thread/?tid=1018&p=3#p41)
|
||||
|
||||
## 渲染层与数据层分离
|
||||
|
||||
渲染层可以直接引用数据层代码,但是数据层**不能**直接引用渲染层代码,具体请查看[系统说明](./system.md#渲染端与数据端通信)
|
||||
|
||||
## 避免循环引用依赖
|
||||
|
||||
需要避免两个包之间循环引入。如果出现了循环引入,请考虑将它们挪到一个包里面,或者将循环的部分单独拿出来作为一个包。
|
||||
@@ -0,0 +1,34 @@
|
||||
---
|
||||
lang: zh-CN
|
||||
---
|
||||
|
||||
# 差异说明
|
||||
|
||||
本文档暂时只会对新样板新增内容进行说明,其余请查看[旧样板文档](https://h5mota.com/games/template/_docs/#/)。
|
||||
|
||||
本指南建立在你已经大致了解 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 等作为了开发语言。
|
||||
|
||||
本文将描述 2.B 样板与 2.10.3 及 2.A 样板的差异。
|
||||
|
||||
## 注意事项
|
||||
|
||||
对于新样板,由于拥有了近乎完整的类型标注,因此更推荐使用 `VS Code` 进行代码编写,这样你可以获取到完整的类型标注,而由于类型标注的复杂性,样板编辑器完全无法部署,因此样板编辑器不会有任何新版的类型标注。在之后的更新中,样板 API 会进行大幅度的改动,因此每次更新都可能会弃用一部分 API,同时这些 API 会在若干个版本后被彻底删除。因此如果你的代码中使用到了弃用的 API,请尽快更换写法以保证可以向后接档。
|
||||
|
||||
## 主要差异
|
||||
|
||||
- 开发语言换为 TypeScript,可以享受到完整的类型支持
|
||||
- 使用全新的 UI 编写方式,速度快,效率高
|
||||
- 模块化,可以使用 ES6 模块化语法
|
||||
- 移除插件系统,可以自定义代码目录结构,更加自由
|
||||
- 优化渲染端(client 端)与数据端(data 端)的通讯,渲染端现在可以直接引用数据端,不过数据端还不能直接引用渲染端
|
||||
|
||||
## 差异内容
|
||||
|
||||
相比于 2.10.3 及 2.A,有如下改动:
|
||||
|
||||
- [系统说明](./system.md)
|
||||
- [UI 编写](./ui/ui.md)
|
||||
- [UI 系统](./ui/system.md)
|
||||
- [音频系统](./audio.md)
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,3 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<?xml-stylesheet href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.7.2/css/all.min.css" type="text/css"?>
|
||||
<svg aria-roledescription="flowchart-v2" role="graphics-document document" viewBox="0 0 711.1015625 2118" style="max-width: 100%;" class="flowchart" xmlns="http://www.w3.org/2000/svg" width="100%" id="graph-div" height="100%" xmlns:xlink="http://www.w3.org/1999/xlink"><style>#graph-div{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#ccc;}#graph-div .error-icon{fill:#a44141;}#graph-div .error-text{fill:#ddd;stroke:#ddd;}#graph-div .edge-thickness-normal{stroke-width:1px;}#graph-div .edge-thickness-thick{stroke-width:3.5px;}#graph-div .edge-pattern-solid{stroke-dasharray:0;}#graph-div .edge-thickness-invisible{stroke-width:0;fill:none;}#graph-div .edge-pattern-dashed{stroke-dasharray:3;}#graph-div .edge-pattern-dotted{stroke-dasharray:2;}#graph-div .marker{fill:lightgrey;stroke:lightgrey;}#graph-div .marker.cross{stroke:lightgrey;}#graph-div svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#graph-div p{margin:0;}#graph-div .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#ccc;}#graph-div .cluster-label text{fill:#F9FFFE;}#graph-div .cluster-label span{color:#F9FFFE;}#graph-div .cluster-label span p{background-color:transparent;}#graph-div .label text,#graph-div span{fill:#ccc;color:#ccc;}#graph-div .node rect,#graph-div .node circle,#graph-div .node ellipse,#graph-div .node polygon,#graph-div .node path{fill:#1f2020;stroke:#ccc;stroke-width:1px;}#graph-div .rough-node .label text,#graph-div .node .label text,#graph-div .image-shape .label,#graph-div .icon-shape .label{text-anchor:middle;}#graph-div .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#graph-div .rough-node .label,#graph-div .node .label,#graph-div .image-shape .label,#graph-div .icon-shape .label{text-align:center;}#graph-div .node.clickable{cursor:pointer;}#graph-div .root .anchor path{fill:lightgrey!important;stroke-width:0;stroke:lightgrey;}#graph-div .arrowheadPath{fill:lightgrey;}#graph-div .edgePath .path{stroke:lightgrey;stroke-width:2.0px;}#graph-div .flowchart-link{stroke:lightgrey;fill:none;}#graph-div .edgeLabel{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#graph-div .edgeLabel p{background-color:hsl(0, 0%, 34.4117647059%);}#graph-div .edgeLabel rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#graph-div .labelBkg{background-color:rgba(87.75, 87.75, 87.75, 0.5);}#graph-div .cluster rect{fill:hsl(180, 1.5873015873%, 28.3529411765%);stroke:rgba(255, 255, 255, 0.25);stroke-width:1px;}#graph-div .cluster text{fill:#F9FFFE;}#graph-div .cluster span{color:#F9FFFE;}#graph-div div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(20, 1.5873015873%, 12.3529411765%);border:1px solid rgba(255, 255, 255, 0.25);border-radius:2px;pointer-events:none;z-index:100;}#graph-div .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#ccc;}#graph-div rect.text{fill:none;stroke-width:0;}#graph-div .icon-shape,#graph-div .image-shape{background-color:hsl(0, 0%, 34.4117647059%);text-align:center;}#graph-div .icon-shape p,#graph-div .image-shape p{background-color:hsl(0, 0%, 34.4117647059%);padding:2px;}#graph-div .icon-shape rect,#graph-div .image-shape rect{opacity:0.5;background-color:hsl(0, 0%, 34.4117647059%);fill:hsl(0, 0%, 34.4117647059%);}#graph-div :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;}</style><g><marker orient="auto" markerHeight="8" markerWidth="8" markerUnits="userSpaceOnUse" refY="5" refX="5" viewBox="0 0 10 10" class="marker flowchart-v2" id="graph-div_flowchart-v2-pointEnd"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 0 L 10 5 L 0 10 z"></path></marker><marker orient="auto" markerHeight="8" markerWidth="8" markerUnits="userSpaceOnUse" refY="5" refX="4.5" viewBox="0 0 10 10" class="marker flowchart-v2" id="graph-div_flowchart-v2-pointStart"><path style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" d="M 0 5 L 10 10 L 10 0 z"></path></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="11" viewBox="0 0 10 10" class="marker flowchart-v2" id="graph-div_flowchart-v2-circleEnd"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5" refX="-1" viewBox="0 0 10 10" class="marker flowchart-v2" id="graph-div_flowchart-v2-circleStart"><circle style="stroke-width: 1; stroke-dasharray: 1, 0;" class="arrowMarkerPath" r="5" cy="5" cx="5"></circle></marker><marker orient="auto" markerHeight="11" markerWidth="11" markerUnits="userSpaceOnUse" refY="5.2" refX="12" viewBox="0 0 11 11" class="marker cross flowchart-v2" id="graph-div_flowchart-v2-crossEnd"><path style="stroke-width: 2; stroke-dasharray: 1, 0;" class="arrLine truncated
|
||||
|
After Width: | Height: | Size: 35 KiB |
@@ -0,0 +1,186 @@
|
||||
# 动画效果
|
||||
|
||||
2.B 提供了专门的动画接口,允许你用短短几行就可以做出一个效果不错的动画。本节主要讲述的是 UI 中的动画。
|
||||
|
||||
:::warning
|
||||
`mutate-animate` 库在未来会重构,可能会修改引入方式,但整体逻辑不会怎么变。
|
||||
:::
|
||||
|
||||
## 定义动画属性
|
||||
|
||||
我们以一个自定义 UI 为例,来讲述如何编写一段动画。自定义 UI 参考[此指南](./new-ui.md)和[此教程](../../guide/ui/ui.md)。
|
||||
|
||||
假设自定义 UI 在 `packages-user/client-modules/src/render/ui` 文件夹下,我们需要引入 `transitioned` 接口,并调用它定义动画属性:
|
||||
|
||||
```tsx {8-13}
|
||||
// 引入接口,如果文件夹不同,注意路径关系
|
||||
import { transitioned } from '../use';
|
||||
// 从 mutate-animate 库中引入速率曲线函数
|
||||
import { hyper } from 'mutate-animate';
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 定义动画属性
|
||||
const value = transitioned(
|
||||
1, // 初始值是 1
|
||||
500, // 动画时长 500ms
|
||||
hyper('sin', 'out') // 速率曲线是双曲正弦函数(此曲线观感较好)
|
||||
)!;
|
||||
// 上一行的感叹号为非空断言,因为此接口在组件或 UI 外调用会返回 null,
|
||||
// 而我们在 UI 内,因此我们需要断言其不可能为 null
|
||||
|
||||
return () => <container></container>;
|
||||
});
|
||||
```
|
||||
|
||||
注意 `transitioned` 只能在 UI 或组件的顶层调用,不能在 UI 的函数内调用,也不可在 UI 外调用,在其他地方调用会返回 `null`!
|
||||
|
||||
## 添加到元素属性
|
||||
|
||||
定义属性后,我们需要把属性添加到元素上,直接使用 `value.ref.value` 即可,这里以不透明度 `alpha` 属性为例:
|
||||
|
||||
```tsx {9}
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 定义动画属性
|
||||
const value = transitioned(1, 500, hyper('sin', 'out'))!;
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
{/* 将 value.ref.value 赋值给 alpha 属性 */}
|
||||
<g-rect rect={[0, 0, 100, 100]} alpha={value.ref.value} />
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 执行动画
|
||||
|
||||
最后,在我们需要的时候执行动画即可,使用 `value.set`:
|
||||
|
||||
```tsx
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 定义动画属性
|
||||
const value = transitioned(1, 500, hyper('sin', 'out'))!;
|
||||
|
||||
// 例如在 UI 挂载完毕后执行
|
||||
onMounted(() => {
|
||||
// 动画属性调成 0,效果就是矩形逐渐从不透明变成完全透明
|
||||
value.set(0);
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<g-rect rect={[0, 0, 100, 100]} alpha={value.ref.value} />
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-颜色动画
|
||||
|
||||
除了一般数值的动画外,样板还支持颜色动画,使用 `transitionedColor` 接口,用法与 `transitioned` 基本一致:
|
||||
|
||||
```tsx {23}
|
||||
// 引入接口,如果文件夹不同,注意路径关系
|
||||
import { transitionedColor } from '../use'; // [!code ++]
|
||||
// 从 mutate-animate 库中引入速率曲线函数
|
||||
import { hyper } from 'mutate-animate';
|
||||
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 定义颜色动画
|
||||
const color = transitionedColor(
|
||||
'#fff', // 初始颜色,设定为白色 // [!code ++]
|
||||
1000, // 动画时长 1s
|
||||
hyper('sin', 'out') // 动画速率曲线
|
||||
)!;
|
||||
|
||||
// 例如在 UI 挂载完毕后执行
|
||||
onMounted(() => {
|
||||
// 颜色调成纯黑
|
||||
color.set('#000'); // [!code ++]
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
{/* 将 fillStyle 赋值为 color.ref.value */}
|
||||
<g-rect rect={[0, 0, 100, 100]} fillStyle={color.ref.value} />
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
颜色目前仅支持输入 `#RGB` `#RGBA` `#RRGGBB` `#RRGGBBAA` `rgb(r,g,b)` `rgba(r,g,b,a)` 几种格式的字符串。
|
||||
|
||||
## 拓展-配合交互
|
||||
|
||||
我们可以配合交互实现以下几种功能:
|
||||
|
||||
### 鼠标移入移出
|
||||
|
||||
当鼠标移入时高亮,当移出时恢复:
|
||||
|
||||
```tsx {8-11,19-20}
|
||||
import { transitionedColor } from '../use';
|
||||
import { hyper } from 'mutate-animate';
|
||||
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 初始颜色为灰色
|
||||
const color = transitionedColor('#aaa', 200, hyper('sin', 'out'))!;
|
||||
|
||||
/** 鼠标移入时变成白色 */
|
||||
const enter = () => color.set('#fff');
|
||||
/** 鼠标移出时恢复为灰色 */
|
||||
const leave = () => color.set('#aaa');
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<g-rect
|
||||
rect={[0, 0, 100, 100]}
|
||||
fillStyle={color.ref.value}
|
||||
// 监听 enter 和 leave 事件
|
||||
onEnter={enter}
|
||||
onLeave={leave}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 点击切换状态
|
||||
|
||||
鼠标点击或触碰点击后高亮,再次点击恢复:
|
||||
|
||||
```tsx {8-15,23}
|
||||
import { transitionedColor } from '../use';
|
||||
import { hyper } from 'mutate-animate';
|
||||
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 初始颜色为灰色
|
||||
const color = transitionedColor('#aaa', 200, hyper('sin', 'out'))!;
|
||||
|
||||
let highlight = false;
|
||||
/** 点击时触发 */
|
||||
const click = () => {
|
||||
// 如果已经高亮,那么恢复为灰色,否则高亮为白色
|
||||
color.set(highlight ? '#aaa' : '#fff');
|
||||
// 切换状态
|
||||
highlight = !highlight;
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<g-rect
|
||||
rect={[0, 0, 100, 100]}
|
||||
fillStyle={color.ref.value}
|
||||
// 监听 click 事件
|
||||
onClick={click}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-API 参考
|
||||
|
||||
参考[此 API 文档](../../api/user-client-modules/functions.md#transitioned)。
|
||||
@@ -0,0 +1,281 @@
|
||||
# 选择框与确认框
|
||||
|
||||
2.B 提供的简单的确认框与选择框接口,允许你用几行代码就可以弹出一个确认框让用户确认,或是弹出选择框让用户选择,不再需要像 2.x 一样用非常不好用的 `myconfirm` 或 `insertAction` 事件流了。
|
||||
|
||||
## 确认框
|
||||
|
||||
假设我们需要在点击一个按钮后弹出应该确认框,让玩家确认操作,可以使用 `getConfirm` 或 `routedConfirm` 接口。假设我们在 `packages-user/client-modules/src/render/ui` 文件夹下的 UI 中实现这一需求,我们可以这么写:
|
||||
|
||||
```tsx
|
||||
// 从 components 文件夹引入接口,注意路径关系
|
||||
import { getConfirm } from '../components';
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
/** 当鼠标点击时触发,由于 getConfirm 是异步函数,这里的 click 也需要异步 */
|
||||
const click = async () => {
|
||||
// 调用接口,并等待执行完毕,获取异步返回值
|
||||
const confirm = await getConfirm(
|
||||
props.controller, // 使用打开当前 UI 的控制器作为 UI 控制器
|
||||
'确认要 XXX 吗?', // 提示文字
|
||||
[240, 240, void 0, void 0, 0.5, 0.5], // 确认框所处的位置,这里使用居中对齐,中心点在 (240,240) 位置
|
||||
240, // 确认框的宽度
|
||||
{
|
||||
// 这一参数具体看后面的解释
|
||||
selFill: 'gold' // 选项文字使用金色
|
||||
}
|
||||
);
|
||||
if (confirm) {
|
||||
// 如果用户选择了确认,可以执行确认内容,例如显示一个提示
|
||||
core.drawTip('用户确认');
|
||||
} else {
|
||||
// 如果用户选择了取消,可以执行取消内容
|
||||
core.drawTip('用户取消');
|
||||
}
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<text
|
||||
text="这是一个按钮"
|
||||
// 监听 click 事件
|
||||
onClick={click}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
其中 `getConfirm` 的最后一个参数是一个对象,可以传入 `ConfirmBox` 组件的参数,具体可以参考[此文档](../../api/user-client-modules/组件%20ConfirmBox.md)。例如 `selFill` 是 `ConfirmBox` 控制选项填充样式的参数。
|
||||
|
||||
除此之外,还有一个 `routedConfirm` 接口,此接口与 `getConfirm` 引入方式相同,主要差别为 `routedConfirm` 会自动处理录像。但是这并不意味着用 `routedConfirm` 比 `getConfirm` 更好,因为我们一般会在客户端(渲染端)调用它,而客户端的内容在录像验证时是不会执行的,这就可能导致录像出错。一般情况下,我们只需要使用 `getConfirm` 接口,而需要使用 `routedConfirm` 的场景已经在样板中处理。
|
||||
|
||||
**总结成一句话就是**:一般情况下不要使用 `routedConfirm`,使用 `getConfirm` 即可。
|
||||
|
||||
## 选择框
|
||||
|
||||
选择框会给玩家提供一系列选项,让玩家选择某一项,并返回玩家选择的内容。使用 `getChoice` 接口,示例如下:
|
||||
|
||||
```tsx
|
||||
// 从 components 文件夹引入接口,注意路径关系
|
||||
import { getChoice } from '../components'; // [!code ++]
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
// 选项内容,第一项是 id,第二项是该选项的显示内容
|
||||
// 第一项 id 可以填字符串或者数字
|
||||
const choices: ChoiceItem[] = [
|
||||
['key1', '选项1'], // 第一个选项
|
||||
['key2', '选项2'] // 第二个选项
|
||||
];
|
||||
|
||||
/** 同样,由于 getChoice 是异步函数,这里的 click 也需要异步 */
|
||||
const click = async () => {
|
||||
// 调用接口,并等待执行完毕,获取异步返回值
|
||||
const choice = await getChoice(
|
||||
props.controller, // 使用打开当前 UI 的控制器作为 UI 控制器
|
||||
choices, // 在这里传入选项内容
|
||||
[240, 240, void 0, void 0, 0.5, 0.5], // 选择框所处的位置
|
||||
240, // 选择框的宽度
|
||||
{
|
||||
// 可选参数,例如显示提示文字
|
||||
text: '请选择一项'
|
||||
}
|
||||
);
|
||||
// 使用 switch 判断
|
||||
switch (choice) {
|
||||
case 'key1':
|
||||
// 选择了第一个选项时
|
||||
core.drawTip('选择了第一项');
|
||||
break;
|
||||
// ... 其他判断
|
||||
}
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<text
|
||||
text="这是一个按钮"
|
||||
// 监听 click 事件
|
||||
onClick={click}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
同样,`getChoice` 也有一个对应的 `routedChoice`,依然建议一般情况下只使用 `getChoice`,不使用 `routedChoice`。
|
||||
|
||||
## 拓展-使用枚举定义选择框
|
||||
|
||||
如果使用字符串定义选择框的 `id`,一来没有严格的类型标注,二来字符串的性能也较差,因此我们推荐使用枚举定义选择框的 `id`。首先我们先编写一段枚举:
|
||||
|
||||
```ts
|
||||
// 定义选项枚举,可以写一些有意义的单词辅助记忆
|
||||
const enum MyChoice {
|
||||
Choice1, // 第一个选项
|
||||
Choice2, // 第二个选项
|
||||
Choice3 // 第三个选项
|
||||
}
|
||||
```
|
||||
|
||||
然后在选择框定义中使用枚举定义:
|
||||
|
||||
```ts
|
||||
const choices: Choices<MyChoice>[] = [
|
||||
[MyChoice.Choice1, '选项1'],
|
||||
[MyChoice.Choice2, '选项2'],
|
||||
[MyChoice.Choice3, '选项3']
|
||||
];
|
||||
|
||||
// 这里不变
|
||||
const choice = await getChoice(
|
||||
props.controller
|
||||
choices
|
||||
[240, 240, void 0, void 0, 0.5, 0.5]
|
||||
240
|
||||
);
|
||||
|
||||
// 可以用 switch 判断
|
||||
switch (choice) {
|
||||
case MyChoice.Choice1:
|
||||
// 选择选项1时
|
||||
break;
|
||||
// ... 其他内容
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-等待框
|
||||
|
||||
等待框也是一种类似于确认框的东西,不过它用来等待一个操作执行完毕,同时给用户显示一个界面,可以是复杂逻辑运算,也可以是网络请求等。以等待网络请求为例:
|
||||
|
||||
```tsx
|
||||
// 从 components 文件夹引入接口,注意路径关系
|
||||
import { waitbox } from '../components';
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
const click = async () => {
|
||||
// 等待网络请求,同时展示一个等待框让玩家不孤单
|
||||
const response = await waitbox(
|
||||
props.controller, // 控制器
|
||||
[240, 240, void 0, void 0, 0.5, 0.5], // 位置
|
||||
240, // 宽度
|
||||
fetch('/api/example') // 一个 fetch 请求
|
||||
);
|
||||
// 之后就可以直接使用了
|
||||
const body = response.body;
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<text
|
||||
text="这是一个按钮"
|
||||
// 监听 click 事件
|
||||
onClick={click}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-输入框
|
||||
|
||||
与选择框、确认框类似,只不过允许玩家输入一段内容,然后返回给程序。使用 `getInput` 来让玩家输入字符串,使用 `getInputNumber` 来让玩家输入数字。
|
||||
|
||||
输入框包含一个确认键和取消键,玩家点击确认键时会将输入结果返回,而如果点击了取消,那么会返回空字符串或 `NaN`。
|
||||
|
||||
### 输入字符串
|
||||
|
||||
使用 `getInput` 接口:
|
||||
|
||||
```tsx
|
||||
import { getInput } from '../components';
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
const click = async () => {
|
||||
// 调用接口,并等待执行完毕,获取异步返回值
|
||||
const inputData = await getInput(
|
||||
props.controller, // UI 控制器
|
||||
'请输入一句话', // 显示的文字
|
||||
[240, 240, void 0, void 0, 0.5, 0.5], // 输入框位置
|
||||
240, // 输入框宽度
|
||||
// 其他参数配置,参考 API 文档
|
||||
{
|
||||
// 例如设置一个占位符
|
||||
input: {
|
||||
placeholder: '输入一句话'
|
||||
}
|
||||
}
|
||||
);
|
||||
if (inputData.length === 0) {
|
||||
// 如果用户没有输入任何内容或点了取消
|
||||
} else {
|
||||
// 如果用户输入了内容
|
||||
}
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<text
|
||||
text="这是一个按钮"
|
||||
// 监听 click 事件
|
||||
onClick={click}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 输入数字
|
||||
|
||||
与 `getInput` 类似,不过要用 `getInputNumber` 接口:
|
||||
|
||||
```tsx
|
||||
import { getInputNumber } from '../components';
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
const click = async () => {
|
||||
// 调用接口,并等待执行完毕,获取异步返回值
|
||||
const num = await getInputNumber(
|
||||
props.controller, // UI 控制器
|
||||
'请输入一个数字', // 显示的文字
|
||||
[240, 240, void 0, void 0, 0.5, 0.5], // 输入框位置
|
||||
240, // 输入框宽度
|
||||
// 其他参数配置,参考 API 文档
|
||||
{
|
||||
// 例如设置一个占位符
|
||||
input: {
|
||||
placeholder: '输入数字'
|
||||
}
|
||||
}
|
||||
);
|
||||
if (isNaN(num)) {
|
||||
// 如果用户输入的不是数字或点了取消
|
||||
} else {
|
||||
// 如果用户输入了数字
|
||||
}
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<text
|
||||
text="这是一个按钮"
|
||||
// 监听 click 事件
|
||||
onClick={click}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-API参考
|
||||
|
||||
- [ConfirmBox](../../api/user-client-modules/组件%20ConfirmBox.md)
|
||||
- [Choices](../../api/user-client-modules/组件%20Choices.md)
|
||||
- [WaitBox](../../api/user-client-modules/组件%20WaitBox.md)
|
||||
- [getConfirm](../../api/user-client-modules/functions.md#getconfirm)
|
||||
- [getChoice](../../api/user-client-modules/functions.md#getchoice)
|
||||
- [waitbox 方法](../../api/user-client-modules/functions.md#waitbox)
|
||||
@@ -0,0 +1,256 @@
|
||||
# 新增按键
|
||||
|
||||
在 2.B 中新增按键很方便,且可以为你的 UI 单独配置按键信息,玩家也可以修改快捷键设置。
|
||||
|
||||
下面以设置一个全局的切换技能按键为例,展示如何新增一个按键。
|
||||
|
||||
:::warning
|
||||
按键系统在未来可能会有小幅重构,但逻辑不会大幅变动。
|
||||
:::
|
||||
|
||||
## 定义按键信息
|
||||
|
||||
我们打开 `packages-user/client-modules/src/action/hotkey.ts`,找到 `//#region 按键实现` 分段,在分段前可以看到一个 `// #endregion`,然后我们在上面的一系列 `register` 后面新增:
|
||||
|
||||
```ts {6-17}
|
||||
gameKey
|
||||
.group(/* ... */)
|
||||
.register({})
|
||||
// ... 原有内容,注意原本内容最后的分号别忘了删除
|
||||
|
||||
//#region 主动技能
|
||||
// 分组,这样既可以方便管理,也可以让玩家设置按键时直接根据分组设置
|
||||
.group('skill', '主动技能')
|
||||
// 注册按键信息
|
||||
.register({
|
||||
// 按键的 id
|
||||
id: '@skill_doubleAttack',
|
||||
// 按键显示的名称
|
||||
name: '二倍斩',
|
||||
// 默认按键,数字 1(非小键盘)
|
||||
defaults: KeyCode.Digit1
|
||||
});
|
||||
```
|
||||
|
||||
此时我们打开游戏,按下 `Esc`,选择 `系统设置->操作设置->自定义按键`,就可以看到我们新增的按键信息了,不过现在按它没有任何作用,因为我们只是定义了按键,还没有编写它的触发效果。
|
||||
|
||||
## 实现按键效果
|
||||
|
||||
我们回到 `hotkey.ts`,翻到文件最后,在最后几行的上面会有一系列 `realize`,这就是实现按键操作的地方,我们在后面新增一个 `@skill_doubleAttack` 的实现:
|
||||
|
||||
```ts {6-10}
|
||||
gameKey
|
||||
.when(/* ... */)
|
||||
.realize(/* ... */)
|
||||
// ... 原有内容
|
||||
|
||||
// 实现刚刚定义的按键
|
||||
.realize('@skill_doubleAttack', () => {
|
||||
// 切换技能
|
||||
toggleSkill();
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-添加辅助按键
|
||||
|
||||
如果我们需要一个按键默认情况下需要按下 `Ctrl` 时才能触发,例如 `Ctrl+A`,我们可以这么写:
|
||||
|
||||
```ts
|
||||
gameKey.register({
|
||||
id: '@skill_doubleAttack',
|
||||
name: '二倍斩',
|
||||
defaults: KeyCode.Digit1,
|
||||
// 设置 ctrl 属性为 true 即可,包括 alt 和 shift 也是一样
|
||||
ctrl: true // [!code ++]
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-在 UI 内实现按键
|
||||
|
||||
有时候,我们需要在一个 UI 界面中提供按键操作支持,样板提供了专门的接口来实现这一点。
|
||||
|
||||
### 定义按键信息
|
||||
|
||||
与[这一节](#定义按键信息)相同,直接定义按键信息即可:
|
||||
|
||||
```ts
|
||||
gameKey
|
||||
//#region 自定义UI
|
||||
.group('@ui_myUI', '自定义UI')
|
||||
.register({
|
||||
id: '@myUI_key',
|
||||
name: '自定义UI',
|
||||
// 默认使用 H 键
|
||||
defaults: KeyCode.KeyH
|
||||
});
|
||||
```
|
||||
|
||||
### 在 UI 内实现按键操作
|
||||
|
||||
按键实现方式略有变动,我们需要使用 `useKey` 接口来实现按键。假设我们在 `packages-user/client-modules/src/render/ui` 文件夹下编写 UI,那么可以这么写:
|
||||
|
||||
```tsx {7-13}
|
||||
// 引入 useKey 接口
|
||||
// 文件在 packages-user/client-modules/src/render/use.ts,注意路径关系
|
||||
import { useKey } from '../use'; // [!code ++]
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
// 调用 useKey
|
||||
const [key] = useKey();
|
||||
// 直接开始实现,本例按键效果为显示一个提示
|
||||
key.realize('@myUI_key', () => {
|
||||
// 调用 drawTip 显示提示
|
||||
core.drawTip('这是一个提示');
|
||||
});
|
||||
|
||||
return () => <container></container>;
|
||||
});
|
||||
```
|
||||
|
||||
### 通用按键复用
|
||||
|
||||
我们会有一些通用按键,例如确认、关闭,这些按键我们不希望每个 UI 或场景都定义一遍,一来写代码不方便,二来玩家如果要自定义的话需要每个界面都设置一遍,很麻烦。此时我们建议按键复用。与一般的按键一致,我们直接实现 `exit` `confirm` 等按键即可,不需额外操作:
|
||||
|
||||
```tsx {12-21}
|
||||
import { useKey } from '../use';
|
||||
|
||||
// UI 模板及如何编写 UI 参考 “新增 UI” 需求指南,这里只给出必要的修改部分,模板部分不再给出
|
||||
export const MyCom = defineComponent(props => {
|
||||
// 调用 useKey
|
||||
const [key] = useKey();
|
||||
// 直接开始实现,本例按键效果为显示一个提示
|
||||
key.realize('@myUI_key', () => {
|
||||
// 调用 drawTip 显示提示
|
||||
core.drawTip('这是一个提示');
|
||||
})
|
||||
// 关闭操作
|
||||
.realize('exit', () => {
|
||||
// 调用关闭函数
|
||||
props.controller.close(props.instance);
|
||||
})
|
||||
// 确认操作
|
||||
.realize('confirm', () => {
|
||||
// 弹出提示说明按下了确认键
|
||||
core.drawTip('按下了确认键!');
|
||||
});
|
||||
|
||||
return () => <container></container>;
|
||||
});
|
||||
```
|
||||
|
||||
实际上,你甚至可以在一个 UI 中实现另一个 UI 定义的按键,虽然这么做非常离谱。
|
||||
|
||||
## 拓展-单功能多按键
|
||||
|
||||
在游戏中可以发现退出、确认等功能可以设定多个按键,为了实现这种按键,我们只需要在定义按键时加上 `_num` 后缀即可,例如:
|
||||
|
||||
```ts {4,10,16}
|
||||
gameKey
|
||||
.register({
|
||||
// 添加 _1 后缀
|
||||
id: '@skill_doubleAttack_1',
|
||||
name: '二倍斩',
|
||||
defaults: KeyCode.Digit1
|
||||
})
|
||||
.register({
|
||||
// 添加 _2 后缀
|
||||
id: '@skill_doubleAttack_2',
|
||||
name: '二倍斩',
|
||||
defaults: KeyCode.Digit2
|
||||
})
|
||||
.register({
|
||||
// 添加 _3 后缀
|
||||
id: '@skill_doubleAttack_3',
|
||||
name: '二倍斩',
|
||||
defaults: KeyCode.Digit3
|
||||
});
|
||||
```
|
||||
|
||||
这样,在自定义按键界面就会显示为可以自定义三个按键。而在实现时,我们不需要添加后缀:
|
||||
|
||||
```ts {2}
|
||||
// 这里不需要添加后缀!
|
||||
gameKey.realize('@skill_doubleAttack', () => {
|
||||
toggleSkill();
|
||||
});
|
||||
```
|
||||
|
||||
或者,添加后缀的话,会精确匹配到对应后缀的按键:
|
||||
|
||||
```ts {2}
|
||||
// 只有按下 @skill_doubleAttack_1 对应的按键才会触发,而 @skill_doubleAttack_2 等不会触发!
|
||||
gameKey.realize('@skill_doubleAttack_1', () => {
|
||||
toggleSkill();
|
||||
});
|
||||
```
|
||||
|
||||
## 拓展-按下时触发
|
||||
|
||||
默认情况下,我们实现的按键都是在按键抬起时触发,如果我们需要按下时触发,我们需要在调用 `realize` 函数时额外传入一个配置项:
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [down]
|
||||
gameKey.realize(
|
||||
'@skill_doubleAttack',
|
||||
() => {
|
||||
toggleSkill();
|
||||
},
|
||||
// 按下时单次触发
|
||||
{ type: 'down' } // [!code ++]
|
||||
);
|
||||
```
|
||||
|
||||
```ts [down-repeat]
|
||||
gameKey.realize(
|
||||
'@skill_doubleAttack',
|
||||
() => {
|
||||
toggleSkill();
|
||||
},
|
||||
// 按下时持续触发
|
||||
{ type: 'down-repeat' } // [!code ++]
|
||||
);
|
||||
```
|
||||
|
||||
```ts [down-throttle]
|
||||
gameKey.realize(
|
||||
'@skill_doubleAttack',
|
||||
() => {
|
||||
toggleSkill();
|
||||
},
|
||||
// 按下时节流触发,节流间隔为 100ms
|
||||
{ type: 'down-throttle', throttle: 100 } // [!code ++]
|
||||
);
|
||||
```
|
||||
|
||||
```ts [down-timeout]
|
||||
gameKey.realize(
|
||||
'@skill_doubleAttack',
|
||||
() => {
|
||||
toggleSkill();
|
||||
},
|
||||
// 按下时延迟触发,延迟 1000ms
|
||||
{ type: 'down-timeout', timeout: 1000 } // [!code ++]
|
||||
);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
这里的 `type` 可以填这些值:
|
||||
|
||||
- `up`: 抬起时触发,默认就是它。
|
||||
- `down`: 按下时触发,只触发一次。
|
||||
- `down-repeat`: 按下时触发,且会重复触发。这一操作可能会与键盘或系统设置有关,一般来说首次触发后会有 `500ms` 的延时,然后每帧触发一次。
|
||||
- `down-throttle`: 按下时节流触发,在 `down-repeat` 的基础上,每隔一段时间才会触发一次,例如可以设定为 `100ms` 触发一次。
|
||||
- `down-timeout`: 按下后延迟触发,会在按下后延迟一段时间触发。
|
||||
|
||||
## 拓展-样板为什么不会在 UI 中触发全局按键?
|
||||
|
||||
这是按键系统最实用的功能之一,这个功能允许我们在 UI 中不会触发全局按键,例如在怪物手册中不会触发打开楼传,也不会触发打开系统菜单。你可能会好奇,我们在上面的讲述中似乎并没有哪一行执行了这一操作,那么是如何实现的呢?
|
||||
|
||||
实际上,按键系统内部有一个栈,而我们调用 `useKey` 时就会自动创建一个新的作用域,同时在关闭 UI 时释放作用域。这样的话,我们在打开 UI 时,按键实现就会遵循新创建的作用域,关闭时自动回到上一层,这就实现了上述功能。
|
||||
|
||||
## 拓展-API 参考
|
||||
|
||||
[Hotkey API 文档](../../api/motajs-system-action/Hotkey.md)
|
||||
@@ -0,0 +1,42 @@
|
||||
# 常见需求的实现指南
|
||||
|
||||
一般情况下,我们只需要在 `packages-user` 文件夹下编写代码。而在此文件夹中,多数文件夹又是样板处理,我们基本只会在这些文件夹中编写:
|
||||
|
||||
- `packages-user/client-modules`:客户端代码
|
||||
- `packages-user/data-state`:数据端代码
|
||||
|
||||
## 客户端内容
|
||||
|
||||
以下内容中,一级列表是基础需求指南,二级列表是拓展需求指南。
|
||||
|
||||
- [修改状态栏显示](./status-bar.md)
|
||||
- [可交互按钮](./status-bar.md#拓展-可交互按钮)
|
||||
- [新增勇士属性](./status-bar.md#拓展-新增勇士属性) (既包含客户端,也包含数据端)
|
||||
- [编写新 UI](./new-ui.md)
|
||||
- [UI 与组件的区别](./new-ui.md#拓展-ui-与组件的区别)
|
||||
- [UI 编写参考](../ui/ui.md)
|
||||
- [新增按键](./hotkey.md)
|
||||
- [添加辅助按键](./hotkey.md#拓展-添加辅助按键)
|
||||
- [在 UI 内实现按键](./hotkey.md#拓展-在-ui-内实现按键)
|
||||
- [单功能多按键](./hotkey.md#拓展-单功能多按键)
|
||||
- [按下时触发](./hotkey.md#拓展-按下时触发)
|
||||
- [动画效果](./animate.md)
|
||||
- [颜色动画](./animate.md#拓展-颜色动画)
|
||||
- [配合交互](./animate.md#拓展-配合交互)
|
||||
- [选择框与确认框](./choice.md)
|
||||
- [使用枚举定义选择框](./choice.md#拓展-使用枚举定义选择框)
|
||||
- [等待框](./choice.md#拓展-等待框)
|
||||
- [输入框](./choice.md#拓展-输入框)
|
||||
|
||||
## 数据端内容
|
||||
|
||||
以下内容中,一级列表是基础需求指南,二级列表是拓展需求指南。
|
||||
|
||||
- [怪物特殊属性](./special.md)
|
||||
- [用函数声明属性](./special.md#拓展-用函数声明属性)
|
||||
- [地图伤害](./special.md#拓展-地图伤害)
|
||||
- [光环属性](./special.md#拓展-光环属性)
|
||||
- [主动技能](./skill.md)
|
||||
- [多技能设计思路](./skill.md#拓展-多技能设计思路)
|
||||
- [战后自动关闭技能](./skill.md#拓展-战后自动关闭技能)
|
||||
- [在开启或关闭技能时执行内容](./skill.md#拓展-在开启或关闭技能时执行内容)
|
||||
@@ -0,0 +1,115 @@
|
||||
# 编写新 UI
|
||||
|
||||
在 `packages-user/client-modules/src/render/ui` 文件夹下创建一个新的 UI 文件,编写完 UI 后在你需要打开此 UI 的地方调用 `mainUIController.open` 即可。
|
||||
|
||||
## UI 模板
|
||||
|
||||
UI 编写模板如下:
|
||||
|
||||
```tsx
|
||||
// 引入必要接口
|
||||
import { defineComponent } from 'vue';
|
||||
import { GameUI, UIComponentProps, DefaultProps } from '@motajs/system-ui';
|
||||
import { SetupComponentOptions } from '../components';
|
||||
|
||||
// 定义组件的参数
|
||||
export interface MyComProps extends UIComponentProps, DefaultProps {}
|
||||
|
||||
// 定义组件的参数,需要传递给 vue
|
||||
const myComProps = {
|
||||
// 这两个参数不能少
|
||||
props: ['controller', 'instance']
|
||||
} satisfies SetupComponentOptions<MyComProps>;
|
||||
|
||||
// 定义组件内容
|
||||
export const MyCom = defineComponent<MyComProps>(props => {
|
||||
// 在这里编写你的 UI 即可
|
||||
return () => <container></container>;
|
||||
}, myComProps);
|
||||
|
||||
// 定义 UI 对象
|
||||
export const MyUI = new GameUI('my-ui', MyCom);
|
||||
```
|
||||
|
||||
## 打开 UI
|
||||
|
||||
在需要打开 UI 的地方调用:
|
||||
|
||||
```ts
|
||||
// 在 client-modules 模块外引入
|
||||
import { mainUIController } from '@user/client-modules';
|
||||
// 在 client-modules 模块内引入
|
||||
// 应该从 client-modules/src/render/ui/controller.tsx 中引入,自行根据路径关系引入,或者使用 vscode 的自动补全时会自动帮你引入
|
||||
import { mainUIController } from './ui/controller';
|
||||
// 引入你自己的 UI
|
||||
import { MyUI } from './myUI';
|
||||
|
||||
// 在需要打开时调用,第二个参数为传递给 UI 的参数,即 Props
|
||||
mainUIController.open(MyUI, {});
|
||||
```
|
||||
|
||||
如果需要在 UI 内打开 UI,推荐使用如下方式:
|
||||
|
||||
```tsx
|
||||
export const MyCom = defineComponent<MyComProps>(props => {
|
||||
// 使用 props.controller,适配不同 UI 控制器
|
||||
props.controller.open(MyUI2, {});
|
||||
return () => <container></container>;
|
||||
}, myComProps);
|
||||
```
|
||||
|
||||
## 关闭 UI
|
||||
|
||||
在 UI 内关闭自身使用:
|
||||
|
||||
```tsx
|
||||
export const MyCom = defineComponent<MyComProps>(props => {
|
||||
// 关闭自身
|
||||
props.controller.close(props.instance);
|
||||
return () => <container></container>;
|
||||
}, myComProps);
|
||||
```
|
||||
|
||||
而如果在 UI 外关闭的话,需要接受 `controller.open` 的返回值:
|
||||
|
||||
```ts
|
||||
// 接收返回值
|
||||
const ins = controller.open(MyUI, {});
|
||||
|
||||
// 关闭此 UI
|
||||
controller.close(ins);
|
||||
```
|
||||
|
||||
## UI 编写参考
|
||||
|
||||
参考[此文档](../ui/ui.md),此文档将会教你如何从头开始编写一个 UI,并解释 UI 运行与渲染的基本逻辑。
|
||||
|
||||
## 拓展-UI 与组件的区别
|
||||
|
||||
UI 包含 `controller` `instance` 两个参数,且必须通过 UI 控制器打开,而组件不包含这两个参数,不能由 UI 控制器打开,需要作为组件或 UI 内的组件调用(类似于标签)。可以自行阅读样板自带 UI 与组件,来理解二者的区别。
|
||||
|
||||
或者用模块的角度来说,组件是函数,而 UI 是一整个模块,函数可以调用函数,而自然组件也可以调用组件。UI 由组件和元素构成,就像模块可以由函数和变量构成。
|
||||
|
||||
除此之外,组件不能被定义为 `GameUI`,只有 UI 可以。例如:
|
||||
|
||||
```tsx
|
||||
// 一个没有 controller, instance 的组件
|
||||
export const MyComponent = defineComponent(() => {
|
||||
return () => <container></container>;
|
||||
});
|
||||
|
||||
// 这是不行的!同时 ts 也会为你贴心报错!
|
||||
export const MyComponentUI = new GameUI('my-component', MyComponent);
|
||||
|
||||
// --------------------
|
||||
|
||||
interface MyComProps extends DefaultProps, UIComponentProps {}
|
||||
|
||||
// 一个包含 controller, instance 的组件,此处省略 myComProps 定义
|
||||
export const MyCom = defineComponent<MyComProps>(props => {
|
||||
return () => <container></container>;
|
||||
}, myComProps);
|
||||
|
||||
// 这是可以的,可以被 UIController 打开!
|
||||
export const MyComUI = new GameUI('my-com', MyCom);
|
||||
```
|
||||
@@ -0,0 +1,346 @@
|
||||
# 主动技能
|
||||
|
||||
在数据端新增一个文件定义技能开启与关闭的行为,然后在数据端处理录像,最后处理交互。
|
||||
|
||||
对于键盘,在 `packages-user/client-modules/src/action/hotkey.ts` 中自定义技能按键,并在此处实现。
|
||||
|
||||
对于触屏和鼠标,在 `packages-user/client-modules/src/render/ui/statusBar.tsx` 中提供技能按钮。
|
||||
|
||||
下面以技能“二倍斩”为例,展示如何实现主动技能。
|
||||
|
||||
## 技能开启关闭行为
|
||||
|
||||
在 `packages-user/data-state/src/mechainism` 文件夹下新增一个文件 `skill.ts`,然后打开同一文件夹下的 `index.ts`,写入 `export * from './skill.ts';`。回到 `skill.ts`,开始编写技能的开启与关闭行为。
|
||||
|
||||
由于二倍斩技能本质上是修改战斗函数,因此我们只需要一个变量来存储当前是否开启了技能即可。因此写出如下内容:
|
||||
|
||||
```ts
|
||||
/** 二倍斩技能是否已经开启 */
|
||||
let skill1 = false;
|
||||
|
||||
/** 开启二倍斩技能 */
|
||||
export function enableSkill1() {
|
||||
// 将变量设为 true
|
||||
skill1 = true;
|
||||
// 更新状态栏
|
||||
core.updateStatusBar();
|
||||
}
|
||||
|
||||
/** 关闭二倍斩技能 */
|
||||
export function disableSkill1() {
|
||||
skill1 = false;
|
||||
core.updateStatusBar();
|
||||
}
|
||||
|
||||
/** 获取二倍斩技能是否已经开启 */
|
||||
export function getSkill1Enabled() {
|
||||
return skill1;
|
||||
}
|
||||
|
||||
/** 切换二倍斩技能,如果开启则关闭,否则开启 */
|
||||
export function toggleSkill1() {
|
||||
if (skill1) disableSkill1();
|
||||
else enableSkill1();
|
||||
}
|
||||
```
|
||||
|
||||
## 修改伤害计算
|
||||
|
||||
打开 `packages-user/data-state/src/enemy/damage.ts`,在最后找到 `calDamageWith` 函数,在里面修改勇士的 `heroPerDamage` 即可:
|
||||
|
||||
```ts {12-14}
|
||||
// 文件开头引入刚刚编写的 skill.ts,可以使用自动补全自动引入
|
||||
import { getSkill1Enabled } from '../machanism/skill'; // [!code ++]
|
||||
|
||||
export function calDamageWith(
|
||||
info: EnemyInfo,
|
||||
hero: Partial<HeroStatus>
|
||||
): number | null {
|
||||
// ... 原有内容
|
||||
|
||||
// 在特定位置将勇士伤害乘以 2
|
||||
// 注意需要再回合计算前乘,不然没有效果
|
||||
if (getSkill1Enabled()) {
|
||||
heroPerDamage *= 2;
|
||||
}
|
||||
|
||||
// ... 原有内容
|
||||
}
|
||||
```
|
||||
|
||||
## 录像处理
|
||||
|
||||
录像处理其实很简单,我们只需要简单修改我们刚刚编写的几个函数,并注册一个新录像即可。
|
||||
|
||||
我们先在 `skill.ts` 中编写一个 `createSkill` 函数,注册录像行为:
|
||||
|
||||
```ts
|
||||
export function createSkill() {
|
||||
// 样板接口,注册录像行为
|
||||
core.registerReplayAction('skill1', action => {
|
||||
// action 可能是 skill1:1 或者 skill1:0
|
||||
// 前者表示开启技能,后者表示关闭
|
||||
if (!action.startsWith('skill1:')) return;
|
||||
// 获取应该开启还是关闭
|
||||
const [, param] = action.split(':');
|
||||
const enable = parseInt(param) === 1;
|
||||
// 执行开启或关闭行为
|
||||
// 由于是在同一个文件,因此是不需要引入的
|
||||
if (enable) enableSkill1();
|
||||
else disableSkill1();
|
||||
|
||||
// 这一句不能少
|
||||
core.replay();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
然后我们再次进入 `index.ts`,在 `createMechanism` 函数中调用 `createSkill`:
|
||||
|
||||
```ts
|
||||
import { createSkill } from './skill'; // [!code ++]
|
||||
|
||||
export function createMechanism() {
|
||||
// ... 原有内容
|
||||
createSkill(); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
最后简单修改一下 `enableSkill1` 和 `disableSkill1` 即可:
|
||||
|
||||
```ts
|
||||
/** 开启二倍斩技能 */
|
||||
export function enableSkill1() {
|
||||
skill1 = true;
|
||||
core.updateStatusBar();
|
||||
// 将开启技能行为计入录像
|
||||
core.status.route.push('skill1:1'); // [!code ++]
|
||||
}
|
||||
|
||||
/** 关闭二倍斩技能 */
|
||||
export function disableSkill1() {
|
||||
skill1 = false;
|
||||
core.updateStatusBar();
|
||||
// 将关闭技能行为计入录像
|
||||
core.status.route.push('skill1:0'); // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
## 按键交互与点击交互
|
||||
|
||||
按键交互参考[此文档](./hotkey.md)
|
||||
|
||||
点击交互参考[此文档](./status-bar.md#拓展-可交互按钮)
|
||||
|
||||
最终实现参考(按键和点击):
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [按键]
|
||||
// 引入刚刚编写的函数
|
||||
import { toggleSkill1 } from '@user/data-state';
|
||||
|
||||
gameKey
|
||||
// 按键分组
|
||||
.group('skill', '主动技能')
|
||||
// 按键注册
|
||||
.register({
|
||||
id: 'skill1',
|
||||
name: '二倍斩',
|
||||
defaults: KeyCode.Digit1
|
||||
});
|
||||
|
||||
// 按键实现
|
||||
gameKey.realize('skill1', toggleSkill1);
|
||||
```
|
||||
|
||||
```tsx [点击]
|
||||
// 引入刚刚编写的函数
|
||||
import { toggleSkill1 } from '@user/data-state'; // [!code ++]
|
||||
|
||||
// 在状态栏新增
|
||||
export const LeftStatusBar = defineComponent<StatusBarProps<ILeftHeroStatus>>(
|
||||
p => {
|
||||
return () => (
|
||||
<container>
|
||||
{/* ... 原有内容 */}
|
||||
|
||||
{/* 新增一个 text 标签,点击时执行 toggleSkill1 切换技能 */}
|
||||
<text // [!code ++]
|
||||
text="切换二倍斩" // [!code ++]
|
||||
cursor="pointer" // [!code ++]
|
||||
onClick={toggleSkill1} // [!code ++]
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## 拓展-多技能设计思路
|
||||
|
||||
很多时候我们可能会有多个技能,且多个技能间互斥,即每次只能开启一个技能,这时候如果我们给每个技能都单独编写一套 `enable` `disable` `toggle` 会显得很啰嗦,也不易维护。
|
||||
|
||||
### 枚举定义
|
||||
|
||||
此时我们可以考虑使用枚举方式来定义:
|
||||
|
||||
```ts
|
||||
export const enum SkillType {
|
||||
None, // 未开启技能
|
||||
DoubleAttack, // 二倍斩
|
||||
TripleAttack // 三倍斩
|
||||
// ... 其他技能
|
||||
}
|
||||
```
|
||||
|
||||
### 修改开启关闭行为
|
||||
|
||||
然后给 `enable` 系列函数添加一个参数,来指定开启某个技能:
|
||||
|
||||
```ts
|
||||
/** 当前开启了什么技能 */
|
||||
let enabled: SkillType = SkillType.None;
|
||||
|
||||
export function enableSkill(skill: SkillType) {
|
||||
// 如果要开启的和当前技能一致,则不做任何事
|
||||
if (enabled === skill) return;
|
||||
// 否则切换当前技能
|
||||
enabled = skill;
|
||||
// 更新状态栏
|
||||
core.updateStatusBar();
|
||||
// 计入录像,直接计入当前开启了什么技能
|
||||
core.status.route.push(`skill:${skill}`);
|
||||
}
|
||||
|
||||
export function disableSkill() {
|
||||
// 关闭技能相当于切换至无技能
|
||||
enableSkill(SkillType.None);
|
||||
}
|
||||
|
||||
export function toggleSkill(skill: SkillType) {
|
||||
// 改为判断是否与当前技能一致,一致则关闭,否则切换至目标技能
|
||||
if (enabled === skill) disableSkill();
|
||||
else enableSkill(skill);
|
||||
}
|
||||
|
||||
export function getEnabledSkill() {
|
||||
return enabled;
|
||||
}
|
||||
```
|
||||
|
||||
### 技能判断
|
||||
|
||||
在其他地方直接判断当前技能,可以使用 `if` 或 `switch`:
|
||||
|
||||
:::code-group
|
||||
|
||||
```ts [if判断]
|
||||
import { getEnabledSkill, SkillType } from './skill';
|
||||
|
||||
export function calDamageWith(
|
||||
info: EnemyInfo,
|
||||
hero: Partial<HeroStatus>
|
||||
): number | null {
|
||||
// ... 原有内容
|
||||
|
||||
// 获取当前开启了什么技能
|
||||
const enabled = getEnabledSkill();
|
||||
// 使用 if 判断
|
||||
if (enabled === SkillType.DoubleAttack) heroPerDamage *= 2;
|
||||
else if (enabled === SkillType.TripleAttack) heroPerDamage *= 3;
|
||||
|
||||
// ... 原有内容
|
||||
}
|
||||
```
|
||||
|
||||
```ts [switch判断]
|
||||
import { getEnabledSkill, SkillType } from './skill';
|
||||
|
||||
export function calDamageWith(
|
||||
info: EnemyInfo,
|
||||
hero: Partial<HeroStatus>
|
||||
): number | null {
|
||||
// ... 原有内容
|
||||
|
||||
// 获取当前开启了什么技能
|
||||
const enabled = getEnabledSkill();
|
||||
// 使用 switch 判断
|
||||
switch (enabled) {
|
||||
case SkillType.DoubleAttack:
|
||||
heroPerDamage *= 2;
|
||||
break;
|
||||
case SkillType.TripleAttack:
|
||||
heroPerDamage *= 3;
|
||||
break;
|
||||
}
|
||||
|
||||
// ... 原有内容
|
||||
}
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### 录像处理
|
||||
|
||||
录像直接改为开启目标技能即可:
|
||||
|
||||
```ts
|
||||
export function createSkill() {
|
||||
// 样板接口,注册录像行为
|
||||
core.registerReplayAction('skill', action => {
|
||||
if (!action.startsWith('skill:')) return;
|
||||
// 获取应该开启的技能
|
||||
const [, param] = action.split(':');
|
||||
const skill = parseInt(param);
|
||||
// 开启目标技能,由于关闭技能就是开启 SkillType.None,因此这里直接这么写就行
|
||||
enableSkill(skill);
|
||||
|
||||
// 这一句不能少
|
||||
core.replay();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-战后自动关闭技能
|
||||
|
||||
可以使用战后的钩子实现,写在 `createSkill` 函数中,具体实现方式如下:
|
||||
|
||||
```ts {7-10}
|
||||
import { hook } from '@user/data-base';
|
||||
|
||||
export function createSkill() {
|
||||
// ... 原有内容
|
||||
|
||||
// 战后钩子,会在战后自动执行
|
||||
hook.on('afterBattle', () => {
|
||||
// 战后直接关闭技能即可
|
||||
disableSkill();
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-在开启或关闭技能时执行内容
|
||||
|
||||
直接在 `enableSkill` 里面编写即可,如果是单技能,那么直接编写内容,否则需要判断:
|
||||
|
||||
```ts {5-15}
|
||||
export function enableSkill(skill: SkillType) {
|
||||
// ... 原有内容
|
||||
|
||||
// 使用 switch 判断
|
||||
switch (skill) {
|
||||
case SkillType.None:
|
||||
// 显示提示
|
||||
core.drawTip('已关闭技能!');
|
||||
break;
|
||||
case SkillType.DoubleAttack:
|
||||
// 显示提示
|
||||
core.drawTip('已开启二倍斩!');
|
||||
break;
|
||||
// ... 其他判断
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,265 @@
|
||||
# 怪物特殊属性
|
||||
|
||||
下面以特殊属性“勇士造成的伤害减少 10%”为例,展示如何自定义一个仅修改伤害计算的特殊属性。
|
||||
|
||||
:::warning
|
||||
伤害计算将会在 2.C 中重构,不过逻辑并不会有大幅变动。
|
||||
:::
|
||||
|
||||
## 编写属性定义
|
||||
|
||||
打开 `packages-user/data-state/src/enemy/special.ts`,在最后添加一个属性定义:
|
||||
|
||||
```ts
|
||||
export const specials: SpecialDeclaration[] = [
|
||||
// ... 原有属性定义
|
||||
|
||||
// 自定义属性
|
||||
{
|
||||
code: 30, // 特殊属性代码,用于 hasSpecial 判断 // [!code ++]
|
||||
name: '自定义特殊属性', // 特殊属性名称 // [!code ++]
|
||||
desc: '勇士对该怪物造成的伤害减少 10%', // 特殊属性描述 // [!code ++]
|
||||
color: '#ffd' // 特殊属性显示的颜色 // [!code ++]
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
## 实现特殊属性
|
||||
|
||||
打开 `packages-user/data-state/src/enemy/damage.ts`,在文件最后的 `calDamageWithTurn` 函数中编写:
|
||||
|
||||
```ts
|
||||
export function calDamageWithTurn(
|
||||
info: EnemyInfo,
|
||||
hero: Partial<HeroStatus>
|
||||
): DamageWithTurn {
|
||||
// ... 原有内容
|
||||
|
||||
// 在需要降低勇士伤害的地方将勇士伤害乘以 0.9 即可
|
||||
// 注意需要再回合计算前乘,不然没有效果
|
||||
heroPerDamage *= 0.9; // [!code ++]
|
||||
|
||||
// ... 原有内容
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-用函数声明属性
|
||||
|
||||
特殊属性的属性名和介绍可以使用函数来声明,这允许属性名和描述根据怪物属性变化。下面我们以特殊属性“勇士伤害减少`n%`”为例,展示如何声明这种类型的属性。
|
||||
|
||||
### 编辑表格
|
||||
|
||||
首先我们打开编辑器,选中任意一个怪物,在左侧属性栏上方找到`编辑表格`,然后点击它打开,找到`【怪物】相关的表格配置`,在 `_data` 属性下仿照攻击或其他属性新增一项,注意不要忘记了逗号:
|
||||
|
||||
```js {4-10}
|
||||
"enemys": {
|
||||
"_data": {
|
||||
// 属性名为 myAttr
|
||||
"myAttr": {
|
||||
"_leaf": true,
|
||||
"_type": "textarea",
|
||||
// 属性说明
|
||||
"_docs": "伤害减免",
|
||||
"_data": "伤害减免"
|
||||
},
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 类型声明
|
||||
|
||||
然后打开 `src/types/declaration/event.d.ts`,找到开头的 `type PartialNumbericEnemyProperty =`,在后面新增一行:
|
||||
|
||||
```ts
|
||||
type PartialNumbericEnemyProperty =
|
||||
| 'value'
|
||||
// ... 其他属性声明
|
||||
|
||||
// 新增自己的 myAttr 属性
|
||||
// 注意不要忘记删除前一行最后的分号
|
||||
| 'myAttr'; // [!code ++]
|
||||
```
|
||||
|
||||
### 属性定义
|
||||
|
||||
最后在 `special.ts` 中新增属性定义即可:
|
||||
|
||||
```ts
|
||||
export const specials: SpecialDeclaration[] = [
|
||||
// ... 原有属性定义
|
||||
|
||||
// 自定义属性
|
||||
{
|
||||
code: 30, // 特殊属性代码,用于 hasSpecial 判断
|
||||
name: enemy => `${enemy.myAttr ?? 0}%减伤`, // 特殊属性名称 // [!code ++]
|
||||
desc: enemy => `勇士对该怪物造成的伤害减少${enemy.myAttr ?? 0}%`, // 特殊属性描述 // [!code ++]
|
||||
color: '#ffd' // 特殊属性显示的颜色
|
||||
}
|
||||
];
|
||||
```
|
||||
|
||||
此时,如果给怪物的 `myAttr` 栏填写 `10`,那么特殊属性名称就会显示 `10%减伤`,属性描述会显示 `勇士对该怪物造成的伤害减少10%`。
|
||||
|
||||
### 属性实现
|
||||
|
||||
修改 `damage.ts` `calDamageWithTurn` 中的实现:
|
||||
|
||||
```ts
|
||||
export function calDamageWithTurn(
|
||||
info: EnemyInfo,
|
||||
hero: Partial<HeroStatus>
|
||||
): DamageWithTurn {
|
||||
// ... 原有内容
|
||||
|
||||
// 在乘以 1 - (myAttr / 100),除以 100 是因为 myAttr 是百分制
|
||||
heroPerDamage *= 1 - (info.myAttr ?? 0) / 100; // [!code ++]
|
||||
|
||||
// ... 原有内容
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-地图伤害
|
||||
|
||||
同样在 `damage.ts`,找到 `DamageEnemy.calMapDamage` 方法,直接 `ctrl+F` 搜索 `calMapDamage` 即可找到,然后在其中编写地图伤害即可。以领域为例,它是这么写的:
|
||||
|
||||
```ts
|
||||
class DamageEnemy {
|
||||
calMapDamage(
|
||||
damage: Record<string, MapDamage> = {},
|
||||
hero: Partial<HeroStatus> = getHeroStatusOn(realStatus)
|
||||
) {
|
||||
// 判断是否包含领域属性
|
||||
if (this.info.special.has(15)) {
|
||||
// 计算领域范围
|
||||
const range = enemy.range ?? 1;
|
||||
const startX = Math.max(0, this.x - range);
|
||||
const startY = Math.max(0, this.y - range);
|
||||
const endX = Math.min(floor.width - 1, this.x + range);
|
||||
const endY = Math.min(floor.height - 1, this.y + range);
|
||||
// 伤害量
|
||||
const dam = enemy.value ?? 0;
|
||||
const objs = core.getMapBlocksObj(this.floorId);
|
||||
|
||||
for (let x = startX; x <= endX; x++) {
|
||||
for (let y = startY; y <= endY; y++) {
|
||||
if (
|
||||
!enemy.zoneSquare &&
|
||||
// 判断非九宫格领域,使用曼哈顿距离判断
|
||||
manhattan(x, y, this.x, this.y) > range
|
||||
) {
|
||||
continue;
|
||||
}
|
||||
const loc = `${x},${y}` as LocString;
|
||||
if (objs[loc]?.event.noPass) continue;
|
||||
// 存储地图伤害
|
||||
this.setMapDamage(damage, loc, dam, '领域');
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-光环属性
|
||||
|
||||
光环计算目前分为两个优先级,高优先级的可以影响低优先级的,这意味着你可以做出来加光环的光环属性。不过高级光环的逻辑比较绕,而且需求不高,这里不再介绍。如果需要的话可以自行理解这部分逻辑或在造塔群里询问。这里以攻击光环为例,展示如何制作一个普通光环。
|
||||
|
||||
我们假设使用 `atkHalo` 作为光环增幅,`haloRange` 作为光环范围,属性代码为 `30`,九宫格光环。我们在 `damage.ts` 中找到 `DamageEnemy.provideHalo` 方法,直接 `ctrl+F` 搜索 `provideHalo` 就能找到。
|
||||
|
||||
### 光环逻辑
|
||||
|
||||
我们直接调用 `applyHalo` 即可,如下编写代码:
|
||||
|
||||
```ts
|
||||
class DamageEnemy {
|
||||
provideHalo() {
|
||||
// ... 原有逻辑
|
||||
|
||||
// 施加光环
|
||||
col.applyHalo(
|
||||
// 光环形状为正方形。目前支持 square 矩形和 rect 矩形
|
||||
'square',
|
||||
// 正方形形状参数
|
||||
{
|
||||
x: this.x, // 中心横坐标
|
||||
y: this.y, // 中心纵坐标
|
||||
d: this.info.haloRange * 2 + 1 // 边长
|
||||
},
|
||||
this, // 填 this 即可
|
||||
(e, enemy) => {
|
||||
// 这里的 e 是指被加成的怪物,enemy 是当前施加光环的怪物
|
||||
// 直接加到 atkBuff_ 属性上即可
|
||||
e.atkBuff_ += enemy.atkHalo;
|
||||
}
|
||||
);
|
||||
|
||||
// 在地图上显示光环,这部分可选,如果不想显示也可以不写
|
||||
col.haloList.push({
|
||||
// 光环形状
|
||||
type: 'square',
|
||||
// 形状参数
|
||||
data: { x: this.x, y: this.y, d: this.info.haloRange * 2 + 1 },
|
||||
// 特殊属性代码
|
||||
special: 30,
|
||||
// 施加的怪物
|
||||
from: this
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 自定义形状
|
||||
|
||||
如果想要自定义光环形状,我们打开 `packages-user/data-utils/src/range.ts`,拉到最后可以看到形状定义,目前默认的包含这些:
|
||||
|
||||
- `square`: 中心点+边长的正方形
|
||||
- `rect`: 左上角坐标+宽高的矩形
|
||||
- `manhattan`: 曼哈顿距离,坐标之和小于半径
|
||||
|
||||
我们以曼哈顿距离为例,展示如何自定义形状。
|
||||
|
||||
首先在开头找到 `interface RangeTypeData`,在其中添加必要的参数类型:
|
||||
|
||||
```ts
|
||||
interface RangeTypeData {
|
||||
// ... 原有内容
|
||||
|
||||
// 自定义的曼哈顿范围参数,包含中心坐标和半径
|
||||
manhattan: { x: number; y: number; dis: number }; // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
然后在文件最后定义形状即可:
|
||||
|
||||
```ts
|
||||
// 这里的第一个参数就是我们的形状名称,填 manhattan 即可
|
||||
// 第二个参数是一个函数,目的是判断 item 是否在范围内
|
||||
Range.register('manhattan', (item, { x, y, dis }) => {
|
||||
// 如果 item 连坐标属性都不存在,那么直接判定不在范围内
|
||||
if (isNil(item.x) || isNil(item.y)) return false;
|
||||
// 计算与中心的坐标差
|
||||
const dx = Math.abs(item.x - x);
|
||||
const dy = Math.abs(item.y - y);
|
||||
// 坐标差之和小于半径则在范围内,否则在范围外
|
||||
return dx + dy < dis;
|
||||
});
|
||||
```
|
||||
|
||||
在光环中,我们就可以直接使用这种形状了:
|
||||
|
||||
```ts {2-9}
|
||||
col.applyHalo(
|
||||
// 使用自定义形状
|
||||
'manhattan',
|
||||
// 自定义形状的参数
|
||||
{
|
||||
x: this.x, // 中心横坐标
|
||||
y: this.y, // 中心纵坐标
|
||||
dis: this.info.haloRange // 半径
|
||||
},
|
||||
this,
|
||||
(e, enemy) => {
|
||||
e.atkBuff_ += enemy.atkHalo;
|
||||
}
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,227 @@
|
||||
# 修改状态栏显示
|
||||
|
||||
在 `packages-user/client-modules/src/render/ui/statusBar.tsx` 中编写,内部包含两个组件 `LeftStatusBar` 和 `RightStatusBar`,分别代表左侧状态栏和右侧状态栏。
|
||||
|
||||
在编写完 UI 之后,还需要在 `packages-user/client-modules/src/render/ui/main.tsx` 中传入必要的参数。
|
||||
|
||||
下面以添加一个自定义 `flag` 的显示为例说明如何新增。
|
||||
|
||||
## 添加属性声明
|
||||
|
||||
首先在 `statusBar.tsx` 中声明:
|
||||
|
||||
```tsx
|
||||
// 这个是文件中自带的接口声明,直接在原有声明的基础上添加即可
|
||||
interface ILeftHeroStatus {
|
||||
// ... 原有声明
|
||||
|
||||
/**
|
||||
* 自己添加的声明,这里使用这种 jsDoc 注释可以在自动补全中直接查看到。
|
||||
* 自定义 flag 为数字类型。
|
||||
*/
|
||||
myFlag: number; // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
## 添加显示
|
||||
|
||||
然后在 `LeftStatusBar` 中添加此项的显示:
|
||||
|
||||
```tsx {9}
|
||||
export const LeftStatusBar = defineComponent<StatusBarProps<ILeftHeroStatus>>(
|
||||
p => {
|
||||
// ... 原有组件 setup 内容
|
||||
return () => (
|
||||
<container>
|
||||
{/* 原有组件内容 */}
|
||||
|
||||
{/* 然后编写一个 text 标签即可显示,位置可以用 loc 参数指定,字体可以用 font 参数指定 */}
|
||||
<text text={s.myFlag.toString()} />
|
||||
</container>;
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 传入属性值
|
||||
|
||||
在 `main.tsx` 中传入自定义 `flag`。首先找到 `//#region 状态更新` 分段,在其上方有 `leftStatus` 的定义,此时它会报错,是因为你在 `ILeftHeroStatus` 中定义了 `myFlag`,而此处没有定义其初始值,为其赋初始值 0 即可:
|
||||
|
||||
```tsx
|
||||
const leftStatus: ILeftHeroStatus = reactive({
|
||||
// ...原有定义
|
||||
|
||||
// 然后添加自己的定义,注意不要忘记了在前一个属性后面加逗号
|
||||
myFlag: 0 // [!code ++]
|
||||
});
|
||||
```
|
||||
|
||||
在 `//#region` 分段下方找到 `updateStatus` 函数,它内部会有一系列 `leftStatus` 的赋值:
|
||||
|
||||
```tsx
|
||||
leftStatus.atk = getHeroStatusOn('atk');
|
||||
leftStatus.hp = getHeroStatusOn('hp');
|
||||
leftStatus.def = getHeroStatusOn('def');
|
||||
// ...其他赋值
|
||||
```
|
||||
|
||||
我们在其后面添加一个 `myFlag` 的赋值即可:
|
||||
|
||||
```tsx
|
||||
// 将 flags.myFlag 赋值到 leftStatus.myFlag
|
||||
leftStatus.myFlag = flags.myFlag;
|
||||
```
|
||||
|
||||
这样,我们就成功新增了一个新的显示项。这一系列操作虽然比 2.x 更复杂,但是其性能表现、规范程度都要更高,你需要习惯这种代码编写风格。
|
||||
|
||||
## 拓展-可交互按钮
|
||||
|
||||
相比于 2.x,2.B 在交互上会方便地多,如果要添加一个可交互的按钮,我们只需要给标签加上 `onClick` 属性,就可以在点击时执行函数了:
|
||||
|
||||
```tsx {7-13}
|
||||
import { IActionEvent } from '@motajs/render';
|
||||
|
||||
export const LeftStatusBar = defineComponent<StatusBarProps<ILeftHeroStatus>>(
|
||||
p => {
|
||||
// ... 原有组件 setup 内容
|
||||
|
||||
const clickText = (ev: IActionEvent) => {
|
||||
// 这里编写点击按钮后的效果,例如切换技能
|
||||
toggleSkill();
|
||||
// 参数 ev 还包含一些属性和方法,例如可以调用 stopPropagation 终止冒泡传播
|
||||
// 这个调用如果不理解不建议随便用,参考 UI 系统的教学文档来理解这句话的含义
|
||||
ev.stopPropagation();
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
{/* 原有组件内容 */}
|
||||
<text
|
||||
text={s.myFlag.toString()}
|
||||
// 当用户点击时执行 clickText // [!code ++]
|
||||
onClick={clickText} // [!code ++]
|
||||
// 鼠标样式变成小手 // [!code ++]
|
||||
cursor="pointer" // [!code ++]
|
||||
/>
|
||||
</container>;
|
||||
);
|
||||
}
|
||||
);
|
||||
```
|
||||
|
||||
## 拓展-新增勇士属性
|
||||
|
||||
在上例中,展示了如何显示一个自定义的 `flag`,但有时候我们需要自定义一个勇士属性,例如攻速、减伤等,这些属性可能会受到全局 `buff` 的影响,这时候使用 `flag` 就不方便,我们推荐使用自定义属性的方式。
|
||||
|
||||
### 定义属性
|
||||
|
||||
我们打开编辑器的全塔属性界面,点击左侧上方的编辑表格按钮,向下滑动找到 `勇士攻击` 等勇士属性(约第 250 行)的位置,仿照这些属性增加一个新的属性,例如添加攻速,取属性名为 `atkSpeed`:
|
||||
|
||||
```js
|
||||
{
|
||||
"mana": {
|
||||
"_leaf": true,
|
||||
"_type": "textarea",
|
||||
"_data": "初始魔力"
|
||||
},
|
||||
"atkSpeed": { // [!code ++]
|
||||
"_leaf": true, // [!code ++]
|
||||
"_type": "textarea", // [!code ++]
|
||||
"_data": "初始攻速" // [!code ++]
|
||||
}, // [!code ++]
|
||||
"atk": {
|
||||
"_leaf": true,
|
||||
"_type": "textarea",
|
||||
"_data": "初始攻击"
|
||||
},
|
||||
}
|
||||
```
|
||||
|
||||
保存后刷新页面,再次进入全塔属性界面,可以看到在勇士属性部分多了一项 `初始攻速`,我们可以输入初始值,例如设为初始值 `1`,表示攻速为 `100%`。
|
||||
|
||||
接下来回到 `vscode`,打开文件 `src/types/declaration/status.d.ts`,按下 `ctrl+F` 搜索 `interface HeroStatus`,在其中新增一个属性值 `atkSpeed`:
|
||||
|
||||
```ts
|
||||
interface HeroStatus {
|
||||
// ... 原有内容
|
||||
|
||||
/** 勇士攻速 */
|
||||
atkSpeed: number; // [!code ++]
|
||||
|
||||
// ... 原有内容
|
||||
}
|
||||
```
|
||||
|
||||
### 容错处理
|
||||
|
||||
为了保证属性添加前的存档还能正常加载,我们需要容错处理,打开编辑器,进入脚本编辑界面,编辑 `重置游戏` 这一脚本编辑项。我们在函数的最后进行容错处理:
|
||||
|
||||
```js
|
||||
function () {
|
||||
// ... 原有内容
|
||||
|
||||
// 容错处理
|
||||
core.status.hero.atkSpeed ??= 1; // [!code ++]
|
||||
}
|
||||
```
|
||||
|
||||
由于样板编辑器的限制,上述代码会有语法报错,但实际上不会有任何问题,关闭语法检查再保存即可。
|
||||
|
||||
### 状态栏显示
|
||||
|
||||
与本文章最初的示例基本一致,只有传入属性值时需要略加变动,其他操作包括属性定义等不改变,将赋值行为改为 `getHeroStatusOn`:
|
||||
|
||||
```ts
|
||||
leftStatus.atk = getHeroStatusOn('atk');
|
||||
leftStatus.hp = getHeroStatusOn('hp');
|
||||
leftStatus.def = getHeroStatusOn('def');
|
||||
// ... 原有内容
|
||||
|
||||
// 改为 getHeroStatusOn
|
||||
leftStatus.atkSpeed = getHeroStatusOn('atkSpeed');
|
||||
```
|
||||
|
||||
状态栏组件中:
|
||||
|
||||
```tsx
|
||||
// 使用模板字符串,显示百分比
|
||||
<text text={`${s.atkSpeed * 100}%`} />
|
||||
```
|
||||
|
||||
### 属性实现
|
||||
|
||||
为了实现勇士属性,我们需要修改伤害计算逻辑。我们打开 `packages-user/data-state/src/enemy/damage.ts`,翻到最后找到 `calDamageWith` 函数,在它上面有一个名为 `realStatus` 的数组,我们在这里新增一项 `atkSpeed`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* 计算伤害时会用到的勇士属性,攻击防御,其余的不会有buff加成,直接从core.status.hero取
|
||||
*/
|
||||
const realStatus: (keyof HeroStatus)[] = [
|
||||
'atk',
|
||||
'def',
|
||||
// ... 原有内容
|
||||
|
||||
// 新增 atkSpeed 属性
|
||||
'atkSpeed' // [!code ++]
|
||||
];
|
||||
```
|
||||
|
||||
然后在 `calDamageWith` 伤害计算中修改伤害计算,给勇士每回合造成的伤害乘以攻速,注意放置的位置:
|
||||
|
||||
```ts
|
||||
export function calDamageWith(
|
||||
info: EnemyInfo,
|
||||
hero: Partial<HeroStatus>
|
||||
): number {
|
||||
// ... 原有逻辑
|
||||
|
||||
// 乘以攻速
|
||||
heroPerDamage *= hero.atkSpeed ?? 1;
|
||||
|
||||
// ... 原有逻辑
|
||||
}
|
||||
```
|
||||
|
||||
## 拓展-了解 UI 编写的基本逻辑
|
||||
|
||||
参考[此文档](../ui/ui.md),此文档将会教你如何从头开始编写一个 UI,并解释 UI 运行与渲染的基本逻辑。
|
||||
@@ -0,0 +1,95 @@
|
||||
# 快速开始
|
||||
|
||||
本章节主要帮助你快速上手 2.B 样板。
|
||||
|
||||
## 常见需求实现指南
|
||||
|
||||
参考[此文档](./implements/index.md)
|
||||
|
||||
## 热重载
|
||||
|
||||
2.B 样板支持热重载,允许一些内容在不刷新页面的情况下就可以直接更新,包括 UI、怪物数据、道具数据等。例如,你在编辑器里面修改了一个怪物的攻击并保存,这时候进入游戏界面,可以直接看到地图上的怪物伤害变化了(怪物手册等 UI 中的怪物数据需要重新打开一次 UI 才会更新)。
|
||||
|
||||
## 启动游戏与编辑器
|
||||
|
||||
在造塔群中的群文件中找到 `启动服务->2.B+ 启动服务`,根据自己设备的系统下载对应的启动服务(此版本不支持移动端造塔),下载后运行安装到自己的设备,可以选择安装路径。
|
||||
|
||||
安装完毕后,打开软件,在左侧点击选择文件夹,然后打开 2.B 样板文件夹,即包含 `package.json` `packages` `packages-user` 这些目录的文件夹,打开错了会提示打开错误。
|
||||
|
||||
然后点击右侧的安装依赖,耐心等待一段时间,等待依赖安装完毕。
|
||||
|
||||
然后点击右侧的启动服务器,等待服务器启动,启动完毕后会自动使用默认浏览器打开游戏、编辑器和帮助文档。
|
||||
|
||||
当你**制作完游戏**后,点击构建游戏,即可自动构建游戏,构建结果在 `dist` 文件夹,会自动打包到 `dist.zip` 压缩包,发塔或更新上传此压缩包即可。
|
||||
|
||||
此帮助文档远比 2.x 的文档易读,也更容易理解,但是遇到问题时我们依然建议直接在造塔群询问,因为你可能不能判断文档中是否有关于你的问题的解答。
|
||||
|
||||
```mermaid
|
||||
graph TD
|
||||
A[下载安装包] --> B[安装启动服务] --> C[运行启动服务] --> D[选择样板文件夹] --> E[安装依赖] --> F[启动服务器];
|
||||
F -- 造完塔 --> G[构建游戏] --> H[发布或更新游戏]
|
||||
```
|
||||
|
||||
## 编辑器
|
||||
|
||||
编辑器与 2.x 的编辑器差别不大,但 2.B 的编辑器多了一些功能:
|
||||
|
||||
- 图片支持 `webp` 格式
|
||||
- 音频支持 `opus` 格式
|
||||
- 新增自动元件自定义连接
|
||||
- 部分事件有改动
|
||||
|
||||
:::info
|
||||
由于 `opus` 格式的音频在体积上有其他音频格式无可比拟的优势,因此建议所有音频使用 `opus` 格式。同样,由于 `webp` 格式的图片在多数情况下比 `png` `jpg` 格式更小,因此建议图片换用无损 `webp` 格式。
|
||||
:::
|
||||
|
||||
### 代码编写
|
||||
|
||||
在 2.B 中,代码编写不再建议使用样板编辑器,因为在插件编写或脚本编辑中调用新接口较为复杂,且无法体验到代码补全、类型检查等实用功能。为了能有更好的开发体验,建议使用 `vscode` 等软件开发,如果不了解 `vscode`,建议查看[这篇文章](https://h5mota.com/bbs/thread/?tid=1018&p=1#p3),这篇文章介绍了 `vscode` 的安装与一些必要插件的安装。或者当你用 `vscode` 打开样板时,`vscode` 也会提示推荐安装的插件,全部安装即可。
|
||||
|
||||
相比于 2.x,2.B 的 `main.js` 允许在 `function main` 的声明之后使用 `ES6+` 语法。
|
||||
|
||||
### TypeScript 类型检查
|
||||
|
||||
`TypeScript` 提供了非常严格的类型检查功能,这可以避免九成因粗心而犯的错误,包括变量名拼写错误、参数传递错误等。`TypeScript` 是 `JavaScript` 的超集,也就是说你可以在 `ts` 中写 `js` 代码,在语法上完全合理。如果不了解 `ts`,可以查看[我编写的教程](https://h5mota.com/bbs/thread/?tid=1018&p=3#p41),不过由于难度较大,如果不能理解也不会有影响,你完全可以在 `ts` 中写 `js`,然后将所有的类型标记为 `any`,虽然这不规范,但是也确实可以避免类型报错。
|
||||
|
||||
## 游戏构建
|
||||
|
||||
由于 2.B 使用了与 2.x 完全不同的技术栈,它并不能直接在浏览器上运行,必须经过构建步骤。2.B 提供了一个新的启动服务来运行样板,它包含两个部分,一部分是开发服务器,这部分运行你直接开发,而不经过构建步骤,包括热重载等非常实用的功能;另一部分是构建,当你做完游戏后,点击构建按钮即可开始构建。需要注意的是,构建要求整个项目没有类型错误,如果包含类型错误,则会在输出中提到(`eslint` 报错不影响)。构建流程基本如下:
|
||||
|
||||
1. 构建代码
|
||||
2. 压缩字体
|
||||
3. 资源分离压缩
|
||||
4. 将游戏压缩为 `zip` 压缩包
|
||||
|
||||
构建流程的自动化程度极高,因此你可以完全专注于游戏的制作,而完全不用关心资源加载等问题(也不需要添加分块加载插件)。
|
||||
|
||||
### 构建代码
|
||||
|
||||
2.B 的代码分为两部分,一部分是客户端(渲染端),另一部分是数据端(服务端),其中客户端会单向引用数据端,而数据端不能直接引用客户端,必须通过必要的接口。这么做是为了让渲染与数据分离,所有的必要逻辑运算都在数据端,这也意味着录像验证只会运行数据端,而不会运行客户端,因此客户端不应该出现会影响录像正确性与一致性的操作。
|
||||
|
||||
由于系统无法判断一个模块属于客户端还是数据端,因此构建时会打包两次:第一次以客户端为入口打包,包含客户端及数据端两部分;第二次以数据端为入口打包,只包含数据端。当在线游戏时,会以客户端入口打开游戏,当录像验证时,会以数据端入口来验证。
|
||||
|
||||
因此,必须要注意的是数据端不能直接引用任何客户端内容,这会直接导致录像验证报错,因为数据端打包也把客户端打包了进去,这是绝对不能出现的情况!而且样板没有办法来检测是否出现了这种情况!如何在数据端引用客户端内容请查看[此文档](./system.md#渲染端与数据端通信)
|
||||
|
||||
### 压缩字体
|
||||
|
||||
字体会使用 `Fontmin` 工具自动压缩,它会扫描项目中所有使用到的文字(不包括代码注释中的,也不包括使用 `String.fromCharCode` 方式创建的文字),然后处理字体,将所有不包含的文字的字体数据排除在外,只包含出现过的文字。这个操作可以大大降低字体大小。
|
||||
|
||||
### 资源分离压缩
|
||||
|
||||
类似于插件库的分块加载优化,不过 2.B 样板的实现与插件完全不同。样板会将所有资源混合打包,即图片可能会与音效打包在同一分块中。每个分块的大小默认为 `2MB`,可以在 `scripts/build-game.ts` 中修改。
|
||||
|
||||
### 压缩游戏
|
||||
|
||||
在上述操作执行完毕后,样板会自动处理一些剩余杂项,然后将打包结果放在 `dist` 文件夹中,此文件即为打包结果,可以直接用旧样板的启动服务打开。除此之外,样板还会自动将此文件夹压缩为 `zip` 压缩包,如果需要更新游戏或发塔,直接上传此压缩包即可,不需要任何额外处理。
|
||||
|
||||
### 协议问题
|
||||
|
||||
2.B 样板换用了 `GPL3.0` 开源协议,这要求所有以此为基础开发的项目也必须完全开源,但考虑到很多作者不了解其中的细节,因此样板将会针对此问题自动处理,处理方案为:**将源码原封不动地打包为压缩包,放到构建完成的游戏中**,届时,只要在网站上下载游戏,就可以解压压缩包查看源码及开源协议。
|
||||
|
||||
同时,这也意味着,如果你使用本样板开发游戏,其他任何人都可以以你的游戏为基础进行二次开发,而不需要你本人同意。如果不想让素材也能被别人使用,可以单独针对素材使用其他协议(如 `CC` 协议,可以上网查询具体信息),而不使用 `GPL3.0` 协议,但代码必须遵循 `GPL3.0` 协议。
|
||||
|
||||
## 学会查阅此文档
|
||||
|
||||
此文档内容很丰富,大部分接口文档都有使用示例,善用此文档可以大幅提高开发效率。
|
||||
@@ -0,0 +1,191 @@
|
||||
---
|
||||
lang: zh-CN
|
||||
---
|
||||
|
||||
# 系统说明
|
||||
|
||||
本文将介绍 2.B 的系统都做了哪些更改
|
||||
|
||||
## 模块化
|
||||
|
||||
2.B 样板现在已经迁移至了 monorepo,将代码模块化,共分为 20 余个模块,每个模块的具体内容可以参考 API 文档,模块列表如下:
|
||||
|
||||
- [@motajs/client](../api/motajs-client/index.md) 渲染层代码
|
||||
- [@motajs/client-base](../api/motajs-client-base/index.md) 渲染层底层代码
|
||||
- [@motajs/common](../api/motajs-common/index.md) 渲染层和数据层通用代码
|
||||
- [@motajs/legacy-client](../api/motajs-legacy-client/index.md) 遗留渲染层代码
|
||||
- [@motajs/legacy-common](../api/motajs-legacy-common/index.md) 遗留通用代码
|
||||
- [@motajs/legacy-system](../api/motajs-legacy-system/index.md) 遗留渲染层系统代码
|
||||
- [@motajs/legacy-ui](../api/motajs-legacy-ui/index.md) 遗留 UI 相关代码
|
||||
- [@motajs/render](../api/motajs-render/index.md) 渲染系统代码
|
||||
- [@motajs/render-core](../api/motajs-render-core/index.md) 渲染系统核心代码
|
||||
- [@motajs/render-elements](../api/motajs-render-elements/index.md) 渲染系统内置元素代码
|
||||
- [@motajs/render-style](../api/motajs-render-style/index.md) 渲染系统样式代码
|
||||
- [@motajs/render-vue](../api/motajs-render-vue/index.md) 渲染系统 vue 支持代码
|
||||
- [@motajs/system](../api/motajs-system/index.md) 渲染层系统代码
|
||||
- [@motajs/system-action](../api/motajs-system-action/index.md) 渲染层交互系统代码
|
||||
- [@motajs/system-ui](../api/motajs-system-ui/index.md) 渲染层 UI 系统代码
|
||||
- [@motajs/types](../api/motajs-types/index.md) 渲染层类型代码
|
||||
- [@user/client-modules](../api/user-client-modules/index.md) 用户渲染层主要代码
|
||||
- [@user/data-base](../api/user-data-base/index.md) 用户数据层底层代码
|
||||
- [@user/data-fallback](../api/user-data-fallback/index.md) 用户数据层向后兼容代码
|
||||
- [@user/data-state](../api/user-data-state/index.md) 用户数据层状态代码
|
||||
- [@user/data-utils](../api/user-data-utils/index.md) 用户数据层工具代码
|
||||
- [@user/entry-client](../api/user-entry-client/index.md) 用户渲染层入口
|
||||
- [@user/entry-data](../api/user-entry-data/index.md) 用户数据层入口
|
||||
- [@user/legacy-plugin-client](../api/user-legacy-plugin-client/index.md) 用户遗留渲染层代码
|
||||
- [@user/legacy-plugin-data](../api/user-legacy-plugin-data/index.md) 用户遗留数据层代码
|
||||
|
||||
## Mota 全局变量
|
||||
|
||||
与 2.A 不同,2.B 对 `Mota` 全局变量做了简化,不再拥有 `Mota.Plugin` `Mota.Package` `Mota.requireAll` 属性与方法,它们全部整合至了 `Mota.require` 方法中,同时该方法的用法与 2.A 也不同,在 2.A 中,我们往往使用 `Mota.require('var', 'xxx')` 的方式调用,繁琐且不直观。在 2.B 中,我们可以直接填入模块名称,就可以获取到其内容了,例如:
|
||||
|
||||
```ts
|
||||
const { hook, loading } = Mota.require('@user/data-base'); // 获取 hook 与 loading
|
||||
const { Font } = Mota.require('@motajs/render'); // 获取 Font 字体类
|
||||
```
|
||||
|
||||
我们只需要填写一个参数,而不需要填写两个参数了,更加直观,而且与 ES6 模块语法类似,便于转换。
|
||||
|
||||
多数情况下,我们是不需要使用 `Mota` 全局变量的。不过,还是有一些特殊情况需要使用该全局变量才可以,这些情况包括:
|
||||
|
||||
- 在数据端调用渲染端接口,数据端需要跑录像验证,因此不能直接引入渲染端接口,需要通过此全局变量才可以。
|
||||
- 在 `libs` `functions.js` 中调用接口,这两个地方暂时还没有模块化,因此无法直接引入,需要通过此全局变量调用。
|
||||
|
||||
## 渲染端与数据端通信
|
||||
|
||||
一般情况下,渲染端**可以**直接引入数据端的内容,例如你可以在 `@user/client-modules` 里面直接引入 `@user/data-state` 的接口,这是没有问题的。不过,由于数据端需要在服务器上跑录像验证,因此**不能**直接引入渲染端的内容,否则会导致验证报错。如果需要在数据端引用渲染端接口,我们需要这么做:
|
||||
|
||||
```ts
|
||||
// @user/data-state 中的某文件
|
||||
const num = 100;
|
||||
Mota.r(() => {
|
||||
// 使用 r 方法包裹,这样这个函数就会在渲染端运行,可以有返回值,但是在录像验证中只会是 undefined
|
||||
const { Font } = Mota.require('@motajs/render');
|
||||
const font = new Font('Verdana', 18);
|
||||
// 函数内也可以调用外部变量,例如这里就调用了外部的 num 变量,但是极度不推荐在渲染端修改数据端的内容
|
||||
// 否则很可能导致录像不能运行,这里这个例子就会导致录像运行出错,因为录像验证时并不会执行这段代码,
|
||||
// 勇士的血量也就不会变大,于是就出错了。
|
||||
core.status.hero.hp += num;
|
||||
});
|
||||
```
|
||||
|
||||
除此之外,我们还可以使用钩子来进行数据通信。示例如下:
|
||||
|
||||
```ts
|
||||
// 渲染端和数据端都可以使用这个方式引入
|
||||
import { hook } from '@user/data-base';
|
||||
// 也可以通过 Mota.require 方法引入
|
||||
const { hook } = Mota.require('@user/data-base');
|
||||
|
||||
// 监听战后函数,每次与怪物战斗后,都会执行这个函数
|
||||
// 每个钩子的参数定义可以参考 package-user/data-base/src/game.ts GameEvent 接口
|
||||
hook.on('afterBattle', enemy => {
|
||||
console.log('与怪物战斗:', enemy.id);
|
||||
});
|
||||
```
|
||||
|
||||
## 加载流程
|
||||
|
||||
与 2.A 相比,加载流程也不太一样,下面是 2.B 的加载流程:
|
||||
|
||||
1. 加载 `index.html`
|
||||
2. 加载 2.x 样板的第三方库
|
||||
|
||||
3. 如果是游戏中,加载 `src/main.ts`
|
||||
1. 加载渲染端入口
|
||||
2. 加载数据端入口
|
||||
3. 并行初始化数据端与渲染端,在数据端写入 `Mota` 全局变量
|
||||
4. 数据端初始化完毕后执行 `loading.emit('dataRegistered')` 钩子,渲染端初始化完毕后执行 `loading.emit('clientRegistered')` 钩子
|
||||
5. 二者都初始化完毕后执行 `loading.emit('registered')` 钩子
|
||||
6. 执行数据端各个模块的初始化函数
|
||||
7. 执行渲染端各个模块的初始化函数
|
||||
|
||||
4. 如果是录像验证中:
|
||||
1. 加载数据端入口
|
||||
2. 初始化数据端,写入 `Mota` 全局变量
|
||||
3. 初始化完毕后执行 `loading.emit('dataRegistered')` 与 `loading.emit('registered')` 钩子
|
||||
4. 执行数据端各个模块的初始化函数
|
||||
|
||||
5. 执行 `main.js` 初始化
|
||||
6. 加载全塔属性
|
||||
7. 加载 `core.js` 及其他 `libs` 中的脚本
|
||||
8. 加载完毕后执行 `loading.emit('coreInit')` 钩子
|
||||
9. 开始资源加载
|
||||
10. 自动元件加载完毕后执行 `loading.emit('autotileLoaded')` 钩子
|
||||
11. 资源加载完毕后执行 `loading.emit('loaded')` 钩子
|
||||
12. 进入标题界面
|
||||
|
||||
使用流程图表示如下:
|
||||
|
||||

|
||||
|
||||
## 函数重写
|
||||
|
||||
在 2.B 模式下,如果想改 `libs` 的内容,如果直接在里面改会很麻烦,而且两端通讯也不方便,因此我们建议在 `package-user` 中对函数重写,这样的话就可以使用模块化语法,更加方便。同时,2.B 也提供了函数重写接口,他在 `@motajs/legacy-common` 模块中,我们可以这么使用它:
|
||||
|
||||
```ts
|
||||
// 新建一个 ts 文件,例如叫做 override.ts,放在 client-modules 文件夹下
|
||||
import { Patch, PatchClass } from '@motajs/legacy-common';
|
||||
|
||||
// 新建函数,这个操作是必要的,我们不能直接在顶层使用这个接口
|
||||
export function patchMyFunctions() {
|
||||
// 创建 Patch 实例,参数表示这个 Patch 示例要重写哪个文件中的函数
|
||||
// 如果需要复写两个文件,那么就需要创建两个实例
|
||||
const patch = new Patch(PatchClass.Control);
|
||||
|
||||
// 使用 add 函数来重写,第一个参数会有自动补全
|
||||
// 如果要重写的函数以下划线开头,可能会有报错
|
||||
// 这时候需要去 types/declaration 中对应的文件中添加声明
|
||||
patch.add('getFlag', (name, defaultValue) => {
|
||||
// 重写 getFlag,如果变量是数字,那么 +100 后返回
|
||||
const value = core.status?.hero?.flags[name] ?? defaultValue;
|
||||
return typeof value === 'number' ? value + 100 : value;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
然后,我们找到 `client-modules` 文件夹下的 `index.ts` 文件,然后在 `create` 函数中引入并调用 `patchMyFunctions`,这样我们的函数重写就完成了。**注意**,如果两个重写冲突,会在控制台弹出警告,并使用最后一次重写的内容。
|
||||
|
||||
**渲染端和数据端均可以调用此接口来重写函数!**
|
||||
|
||||
::: warning
|
||||
**注意**,在渲染端重写的函数在录像验证中将无效,因为录像验证不会执行任何渲染端内容!
|
||||
:::
|
||||
|
||||
## 目录结构
|
||||
|
||||
我们建议每个文件夹中都有一个 `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';
|
||||
```
|
||||
|
||||
:::warning
|
||||
注意,在之后的所有文档示例中,都会使用 `import xxx from '@xxx/xxx'` 的绝对路径形式作为示例,而不会使用相对路径,自己编写代码时请注意要引入的内容是否在当前模块(当前包)中,如果是,请使用相对路径,否则请使用绝对路径。
|
||||
:::
|
||||
|
||||
然后是从当前模块导出内容:
|
||||
|
||||
```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)
|
||||
@@ -0,0 +1,556 @@
|
||||
# 组件使用指南
|
||||
|
||||
2.B 内置了很多实用的组件,本节将会介绍一些常用组件的使用方式。
|
||||
|
||||
## 组件引入
|
||||
|
||||
组件都在 `packages-user/client-modules/src/render/components` 文件夹中,如果在 `ui` 文件夹下引用,注意路径关系,应该如下引入:
|
||||
|
||||
```ts
|
||||
// 从 components 文件夹引入,注意路径关系
|
||||
import { TextContent } from '../components';
|
||||
```
|
||||
|
||||
## 组件与元素
|
||||
|
||||
一般情况下,组件会包含所有元素拥有的参数,例如 `loc` `alpha` `zIndex`,甚至是 `noevent` `cache` 等,也可以监听 `onClick` `onEnter` 这些交互事件。
|
||||
|
||||
除了元素的参数,组件自身可能还会包含一些参数和事件。
|
||||
|
||||
有些组件还提供了暴露接口,可以使用这些接口直接控制组件内部。
|
||||
|
||||
## 多行文本 TextContent
|
||||
|
||||
类似于 2.x 的 `drawTextContent`,可以用于多行文本,包含打字机功能。相比于 `drawTextContent` 的主要优势是支持了英文以及更好的性能表现。
|
||||
|
||||
### 显示文字
|
||||
|
||||
直接调用组件即可:
|
||||
|
||||
```tsx
|
||||
import { TextContent } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 显示一段长文字
|
||||
const toShow = 'man what can i say '.repeat(10);
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
{/* 直接调用组件,其中宽度 width 必填,而 loc 中设定的宽度是无效的 */}
|
||||
<TextContent loc={[0, 0, 240, 200]} width={240} text={toShow} />
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 常用配置
|
||||
|
||||
| 配置 | 数据类型 | 说明 |
|
||||
| ------------- | --------- | ------------------------------------------ |
|
||||
| `font` | `Font` | 文字字体 |
|
||||
| `interval` | `number` | 打字机效果每两个字之间的时间间隔,单位毫秒 |
|
||||
| `lineHeight` | `number` | 行间距,单位像素 |
|
||||
| `fill` | `boolean` | 是否填充文字,默认填充 |
|
||||
| `stroke` | `boolean` | 是否描边文字,默认不描边 |
|
||||
| `fillStyle` | `string` | 文字填充样式 |
|
||||
| `strokeStyle` | `string` | 文字描边样式 |
|
||||
|
||||
使用示例:
|
||||
|
||||
:::code-group
|
||||
|
||||
```tsx [自定义字体]
|
||||
import { Font } from '@motajs/style'; // [!code ++]
|
||||
import { TextContent } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 显示一段长文字
|
||||
const toShow = 'man what can i say '.repeat(10);
|
||||
// 24px 大小的 Arial 字体
|
||||
const myFont = new Font('Arial', 24); // [!code ++]
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<TextContent
|
||||
loc={[0, 0, 240, 200]}
|
||||
width={240}
|
||||
text={toShow}
|
||||
font={myFont} // 使用自定义字体 [!code ++]
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [修改字体颜色]
|
||||
import { TextContent } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 显示一段长文字
|
||||
const toShow = 'man what can i say '.repeat(10);
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<TextContent
|
||||
loc={[0, 0, 240, 200]}
|
||||
width={240}
|
||||
text={toShow}
|
||||
fill // 文字填充 [!code ++]
|
||||
stroke // 文字描边 [!code ++]
|
||||
fillStyle="yellow" // 文字填充为黄色 [!code ++]
|
||||
strokeStyle="cyan" // 文字描边为青色 [!code ++]
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [修改行间距]
|
||||
import { TextContent } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 显示一段长文字
|
||||
const toShow = 'man what can i say '.repeat(10);
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<TextContent
|
||||
loc={[0, 0, 240, 200]}
|
||||
width={240}
|
||||
text={toShow}
|
||||
lineHeight={8} // 行间距为 8px [!code ++]
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
### 自动调整高度
|
||||
|
||||
如果我们不知道这些文字显示的时候会有多高,那么我们可以使用 `autoHeight` 来让组件自动确定自身的高度,可以用于滚动条等场景。
|
||||
|
||||
```tsx
|
||||
import { TextContent } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 显示一段长文字
|
||||
const toShow = 'man what can i say '.repeat(10);
|
||||
|
||||
return () => (
|
||||
<container>
|
||||
<TextContent
|
||||
loc={[0, 0, 240, 200]}
|
||||
width={240}
|
||||
text={toShow}
|
||||
autoHeight // 使用 autoHeight,让组件自动确定自己的高度 [!code ++]
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 转义字符
|
||||
|
||||
与 2.x 类似,2.B 的 `TextContent` 也允许转义字符。具体请参考[此文档](../../api/user-client-modules/TextContentParser.md#转义字符语法说明)。
|
||||
|
||||
### API 参考
|
||||
|
||||
`TextContent` 还有很多功能和特性,这里只做最基础的教学,如果需要其他功能,请参考[API 文档](../../api/user-client-modules/组件%20TextContent.md)
|
||||
|
||||
## 滚动条 Scroll
|
||||
|
||||
`Scroll` 是滚动条组件,如果内容在一个界面大小内显示不下,可以使用滚动条组件,这样玩家可以使用鼠标滚轮或点击拖动来滚动内容。
|
||||
|
||||
### 基本使用
|
||||
|
||||
直接调用组件,然后填写组件内容即可:
|
||||
|
||||
```tsx {7-10}
|
||||
import { Scroll } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
// loc 必填,表示组件位置
|
||||
<Scroll loc={[0, 0, 416, 416]}>
|
||||
<text text="text1" />
|
||||
{/* 省略更多内容 */}
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 平铺布局
|
||||
|
||||
在使用 `Scroll` 组件时,我们推荐使用平铺式布局,即所有内容平铺在 `Scroll` 组件中,而不是将整体用一个 `container` 将它包起来,这非常有助于提高滚动条组件的性能。
|
||||
|
||||
例如下面这种格式就是**好的写法**:
|
||||
|
||||
```tsx {8-10}
|
||||
import { Scroll } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
<Scroll loc={[0, 0, 416, 416]}>
|
||||
{/* 平铺内容,性能表现更好 */}
|
||||
<text text="text1" />
|
||||
<text text="text2" />
|
||||
<text text="text3" />
|
||||
{/* 省略更多内容 */}
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
而下面这种就是**不好的写法**:
|
||||
|
||||
```tsx {8-12}
|
||||
import { Scroll } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
<Scroll loc={[0, 0, 416, 416]}>
|
||||
{/* 用一个 container 包裹,性能表现不好 */}
|
||||
<container>
|
||||
<text text="text1" />
|
||||
<text text="text2" />
|
||||
<text text="text3" />
|
||||
</container>
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 横向滚动条
|
||||
|
||||
添加 `hor` 标记,即可使滚动条变为横向。暂时没有既可以横向又可以纵向的滚动条。
|
||||
|
||||
```tsx {7}
|
||||
import { Scroll } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
// 添加 hor 标记,变为横向
|
||||
<Scroll loc={[0, 0, 416, 416]} hor>
|
||||
<text text="text1" />
|
||||
{/* 省略更多内容 */}
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 隐藏滚动条
|
||||
|
||||
有时候可能需要隐藏滚动条(例如样板的浏览地图界面中左侧的楼层列表),可以使用 `noscroll` 标记实现:
|
||||
|
||||
```tsx {7}
|
||||
import { Scroll } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
// 添加 noscroll 标记,隐藏滚动条
|
||||
<Scroll loc={[0, 0, 416, 416]} noscroll>
|
||||
<text text="text1" />
|
||||
{/* 省略更多内容 */}
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 布局补偿
|
||||
|
||||
有时候我们需要在滚动条滚动到最后时填充一些空白内容,可以使用 `padEnd` 来实现:
|
||||
|
||||
```tsx {7}
|
||||
import { Scroll } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
// 使用 padEnd 在滚动条最后填充空白内容,单位像素
|
||||
<Scroll loc={[0, 0, 416, 416]} padEnd={120}>
|
||||
<text text="text1" />
|
||||
{/* 省略更多内容 */}
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 代码控制滚动条
|
||||
|
||||
可以使用 `Scroll` 提供的接口来用代码控制滚动条:
|
||||
|
||||
```tsx {5-12,15-16}
|
||||
import { Scroll, ScrollExpose } from '../components'; // [!code ++]
|
||||
import { vue } from 'vue'; // [!code ++]
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 使用响应式变量定义 scroll 引用
|
||||
const scrollRef = ref<ScrollExpose>();
|
||||
|
||||
onMounted(() => {
|
||||
// 滚动至 100 像素的位置,动画时长 500ms
|
||||
scrollRef.value?.scrollTo(100, 500);
|
||||
});
|
||||
|
||||
return () => (
|
||||
// 将 ref 属性设为 scrollRef 来获取 Scroll 的接口
|
||||
<Scroll loc={[0, 0, 416, 416]} ref={scrollRef}>
|
||||
<text text="text1" />
|
||||
{/* 省略更多内容 */}
|
||||
</Scroll>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### API 参考
|
||||
|
||||
API 参考[此文档](../../api/user-client-modules/组件%20Scroll.md)。
|
||||
|
||||
## 图标组件
|
||||
|
||||
在 `components/icons.tsx` 中内置了一些图标,可以直接使用。
|
||||
|
||||
### 基本使用
|
||||
|
||||
以箭头图标为例:
|
||||
|
||||
```tsx {8}
|
||||
import { ArrowDownTailless } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
<container>
|
||||
{/* 调用图标组件 */}
|
||||
<ArrowDownTailless loc={[0, 0, 48, 48]} />
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
所有图标都可以填写图形元素的参数,例如 `fill` `stroke` `strokeStyle` `lineWidth` 等。大部分图标默认都是描边样式,如果使用填充可能会导致效果不好。例如修改描边样式和线宽:
|
||||
|
||||
```tsx {8}
|
||||
import { ArrowDownTailless } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
<container>
|
||||
<ArrowDownTailless
|
||||
loc={[0, 0, 48, 48]}
|
||||
strokeStyle="cyan" // 描边使用青色
|
||||
lineWidth={2} // 线宽为 2px
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 图标列表
|
||||
|
||||
目前样板包含这些图标:
|
||||
|
||||
- `RollbackIcon`: 回退图标
|
||||
- `RetweenIcon`: 回收图标
|
||||
- `ViewMapIcon`: 浏览地图图标
|
||||
- `DanmakuIcon`: 弹幕图标
|
||||
- `ReplayIcon`: 回放图标
|
||||
- `numpadIcon`: 数字键盘图标
|
||||
- `PlayIcon`: 开始播放图标
|
||||
- `PauseIcon`: 暂停播放图标
|
||||
- `DoubleArrow`: 双箭头图标(向右)
|
||||
- `StepForward`: 单步向前图标
|
||||
- `SoundVolume`: 音量图标
|
||||
- `Fullscreen`: 全屏图标
|
||||
- `ExitFullscreen`: 退出全屏图标
|
||||
- `ArrowLeftTailless`: 无尾巴左箭头图标
|
||||
- `ArrowRightTailless`: 无尾巴右箭头图标
|
||||
- `ArrowUpTailless`: 无尾巴上箭头图标
|
||||
- `ArrowDownTailless`: 无尾巴下箭头图标
|
||||
|
||||
### API 及参数参考
|
||||
|
||||
参数参考[此文档](../../api/motajs-render-vue/GraphicBaseProps.md)。
|
||||
|
||||
API 参考[此文档](../../api/user-client-modules/图标组件.md)
|
||||
|
||||
## 分页 Page
|
||||
|
||||
分页可以用来展示大量内容,在极端情况下,其性能要比 `Scroll` 更好,但交互手感与易用性不如滚动条。
|
||||
|
||||
### 基本使用
|
||||
|
||||
`Page` 组件需要传入两个必填参数 `pages` 和 `loc`,`pages` 代表总页数,`loc` 代表位置。
|
||||
|
||||
每个页面的内容使用插槽形式传入,接收 `page` 作为参数,代表当前是第几页。你可能不理解这句话,用代码写的话就是这样:
|
||||
|
||||
```tsx {6-17}
|
||||
import { Page } from '../components';
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
return () => (
|
||||
<Page
|
||||
loc={[0, 0, 416, 416]} // 位置
|
||||
pages={10} // 总页码数
|
||||
>
|
||||
{/* 插槽内容,传入一个函数,参数代表当前是第几页 */}
|
||||
{(page: number) => (
|
||||
<container>
|
||||
{/* 这里面填写当前页显示的内容,例如添加一个文字显示当前第几页 */}
|
||||
<text text={page.toString()} />
|
||||
</container>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 第一种设置与获取当前页
|
||||
|
||||
可以通过 `v-model` 指令来创建双向数据绑定,从而达到设置与获取当前页码的功能。
|
||||
|
||||
这种方式一般情况下相对来说没有下一节提到的方式更好,因此相对不推荐。使用示例如下:
|
||||
|
||||
```tsx {7-13}
|
||||
import { Page } from '../components';
|
||||
// 从 vue 引入 ref 响应式函数
|
||||
import { ref, watch } from 'vue'; // [!code ++]
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 定义响应式变量
|
||||
const nowPage = ref(0);
|
||||
|
||||
// 监听当且页码的变化,需要的时候直接用 nowPage.value 获取也可
|
||||
watch(nowPage, value => core.drawTip(`切换至${value}页!`));
|
||||
/** 设置当前页码 */
|
||||
const changePage = (value: number) => void (nowPage.value = value);
|
||||
|
||||
return () => (
|
||||
<Page
|
||||
loc={[0, 0, 416, 416]}
|
||||
pages={10}
|
||||
// 使用 v-model 指令创建双向数据绑定
|
||||
v-model:page={nowPage.value} // [!code ++]
|
||||
>
|
||||
{(page: number) => (
|
||||
<container>
|
||||
<text text={page.toString()} />
|
||||
</container>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
### 第二种设置与获取当前页方式
|
||||
|
||||
相比于上一种方式,我们更推荐使用下面这种方式来设置与获取当前页。
|
||||
|
||||
可以使用 `Page` 组件提供的 `changePage` 和 `movePage` 来切换页码,其中前者是直接切换至某一页,后者是在当前页的基础上移动页码数。
|
||||
|
||||
可以使用 `now` 来获取当前页。
|
||||
|
||||
二者的示例如下:
|
||||
|
||||
:::code-group
|
||||
|
||||
```tsx [切换页码] {6-16,19-20}
|
||||
import { Page, PageExpose } from '../components'; // [!code ++]
|
||||
import { vue } from 'vue'; // [!code ++]
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 使用响应式变量定义 Page 引用
|
||||
const pageRef = ref<PageExpose>();
|
||||
|
||||
onMounted(() => {
|
||||
// 切换至第五页
|
||||
pageRef.value?.changePage(5);
|
||||
// 在当前页的基础上增加两页,也就是切换到第七页
|
||||
pageRef.value?.movePage(2);
|
||||
// 在当前页的基础上减少两页,也就是切换回第五页
|
||||
pageRef.value?.movePage(-2);
|
||||
});
|
||||
|
||||
return () => (
|
||||
// 将 ref 属性赋值为 pageRef 来获取其接口
|
||||
<Page loc={[0, 0, 416, 416]} pages={10} ref={pageRef}>
|
||||
{(page: number) => (
|
||||
<container>
|
||||
<text text={page.toString()} />
|
||||
</container>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
```tsx [获取页码] {6-12,15-16}
|
||||
import { Page, PageExpose } from '../components'; // [!code ++]
|
||||
import { vue } from 'vue'; // [!code ++]
|
||||
|
||||
// 不再展示完整 UI 模板,只展示核心部分
|
||||
export const MyCom = defineComponent(() => {
|
||||
// 使用响应式变量定义 Page 引用
|
||||
const pageRef = ref<PageExpose>();
|
||||
|
||||
onMounted(() => {
|
||||
const page = pageRef.value?.now();
|
||||
core.drawTip(`当前是第${page}页!`);
|
||||
});
|
||||
|
||||
return () => (
|
||||
// 将 ref 属性赋值为 pageRef 来获取其接口
|
||||
<Page loc={[0, 0, 416, 416]} pages={10} ref={pageRef}>
|
||||
{(page: number) => (
|
||||
<container>
|
||||
<text text={page.toString()} />
|
||||
</container>
|
||||
)}
|
||||
</Page>
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
:::
|
||||
|
||||
## 更多组件
|
||||
|
||||
本文只简单讲解了部分常用的组件,样板还有很多内置的组件。以下是所有内置组件的 API 参考:
|
||||
|
||||
- [ConfirmBox](../../api/user-client-modules/组件%20ConfirmBox.md):确认框,一般使用 `getConfirm` 接口,不直接使用组件。
|
||||
- [Choices](../../api/user-client-modules/组件%20Choices.md):选择框,一般使用 `getChoice` 接口,不直接使用组件。
|
||||
- [FloorSelector](../../api/user-client-modules/组件%20FloorSelector.md):楼层选择组件,浏览地图左侧的楼层选择就是使用的本组件。
|
||||
- [图标组件](../../api/user-client-modules/图标组件.md):一些常用图标。
|
||||
- [Input](../../api/user-client-modules/组件%20Input.md):输入组件,可以放到组件内部,可以用于搜索栏等。
|
||||
- [InputBox](../../api/user-client-modules/组件%20InputBox.md):输入框组件,类似于确认框,一般使用 `getInput` 或 `getInputNumber` 接口,不使用本组件。
|
||||
- [List](../../api/user-client-modules/组件%20List.md):列表组件,可以用于展示一列内容。
|
||||
- [ListPage](../../api/user-client-modules/组件%20ListPage.md):左侧是列表,右侧是当前选项对应的详情页,可以用于游戏机制说明等。
|
||||
- [Progress](../../api/user-client-modules/组件%20Progress.md):进度条组件,播放录像时右下角的进度条就是本组件。
|
||||
- [Arrow](../../api/user-client-modules/组件%20Arrow.md):箭头组件,画一个箭头。
|
||||
- [ScrollText](../../api/user-client-modules/组件%20ScrollText.md):滚动文本组件,可以用于长剧情或是 staff 表等。
|
||||
- [Selection](../../api/user-client-modules/组件%20Selection.md):选择光标,列表组件的选择光标就是使用的本组件。
|
||||
- [Background](../../api/user-client-modules/组件%20Background.md):背景组件,可以设置为纯色或 `winskin`。
|
||||
- [WaitBox](../../api/user-client-modules/组件%20WaitBox.md):等待框,一般使用 `waitbox` 接口,不直接使用组件。
|
||||
- [Page](../../api/user-client-modules/组件%20Page.md):分页组件,本文已经详细讲解。
|
||||
- [Scroll](../../api/user-client-modules/组件%20Scroll.md):滚动条组件,本文已经详细讲解。
|
||||
- [TextContent](../../api/user-client-modules/组件%20TextContent.md):多行文本组件,本文已经详细讲解。
|
||||
- [Textbox](../../api/user-client-modules/组件%20Textbox.md):文本框组件,就是事件的显示文字,一般不会直接用。
|
||||
- [Thumbnail](../../api/user-client-modules/组件%20Thumbnail.md):缩略图组件,用于展示某个地图的缩略图。
|
||||
- [Tip](../../api/user-client-modules/组件%20Tip.md):提示组件,就是左上角的提示,一般不会直接使用。
|
||||
@@ -0,0 +1,756 @@
|
||||
# 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
|
||||
<sprite loc={[32, 32, 64, 64, 0.5, 0.5]} />
|
||||
```
|
||||
|
||||
除了 `loc` 属性之外,还可以通过设置 `anc` 属性来修改锚点位置,示例如下:
|
||||
|
||||
```tsx
|
||||
// 设置锚点,效果为靠右对齐,上下居中对齐
|
||||
<sprite anc={[1, 0.5]} />
|
||||
```
|
||||
|
||||
你还可以手动指定 `x` `y` `width` `height` `anchorX` `anchorY` 属性,但是这种方式比较啰嗦,并不建议使用:
|
||||
|
||||
```tsx
|
||||
<sprite x={32} y={32} width={64} height={64} anchorX={0.5} anchorY={0.5} />
|
||||
```
|
||||
|
||||
最后说明一下元素的 `type` 属性,此属性描述了元素的定位模式,默认为 `static` 常规定位,此定位模式下元素位置会按照上述内容更改,而在 `absolute` 模式下,不论怎么修改定位属性,它都会保持在左上角的位置,可能会在一些特殊场景下使用(极度不建议使用此属性,很可能在 2.B.1 版本就会将其删除)。
|
||||
|
||||
### 纵深属性
|
||||
|
||||
可以通过 `zIndex` 属性来调整一个元素的纵深。纵深描述了元素之间的重叠关系,纵深高的会处在纵深低的元素上方,同时也会阻碍交互事件向纵深低的元素传播。必要的时候,需要通过设置纵深属性来调整层级关系。未设置时,后面的元素会处在前面的元素之上。
|
||||
|
||||
```tsx
|
||||
// 这个元素会处在上层
|
||||
<sprite zIndex={10} />
|
||||
// 这个元素会处在下层
|
||||
<sprite zIndex={5} />
|
||||
```
|
||||
|
||||
### 效果属性
|
||||
|
||||
效果属性包含 `filter` `composite` 及 `alpha` 三个属性。
|
||||
|
||||
`filter` 表示此元素的滤镜,参考 [CanvasRenderingContext2D.filter](https://developer.mozilla.org/zh-CN/docs/Web/CSS/filter),可以填写内置函数或 svg 滤镜。默认不包含任何滤镜。示例如下:
|
||||
|
||||
```tsx
|
||||
// 亮度变为 150%,对比度变为 120%
|
||||
<sprite filter="brightness(150%) contrast(120%)" />
|
||||
```
|
||||
|
||||
`composite` 属性描述了当前元素与在此之前渲染的元素之间的混合模式,参考 [CanvasRenderingContext2D.globalCompositeOperation](https://developer.mozilla.org/zh-CN/docs/Web/API/CanvasRenderingContext2D/globalCompositeOperation),可以填写 26 个值。默认使用简单 `alpha` 混合,即 `source-over`。例如:
|
||||
|
||||
```tsx
|
||||
// 使用加算方式叠加,两个颜色的 RGB 值分别相加得到最终结果
|
||||
<sprite composite="lighter" />
|
||||
```
|
||||
|
||||
`alpha` 属性描述了此元素的不透明度,1 表示完全不透明,0 表示完全透明。在叠加时,所有颜色都会乘以此不透明度后叠加。默认值是完全不透明,即 1。但需要注意的是,虽然 1 表示完全不透明,但是如果画布内容本身包含透明内容(例如一个半透明矩形),即使是 1 也会表现为透明,因为叠加时会乘以 1,不透明度不变。示例如下:
|
||||
|
||||
```tsx
|
||||
// 一个半透明元素
|
||||
<sprite alpha={0.5} />
|
||||
```
|
||||
|
||||
### 缓存属性
|
||||
|
||||
可以通过 `cache` 和 `nocache` 属性来指定这个元素的缓存行为,其中 `nocache` 表示禁用此元素的缓存机制,优先级最高,设置后必然不使用缓存。`cache` 表示启用此元素的缓存行为,常用于一些默认不启用缓存的元素,优先级低于 `nocache`。这两个元素都不能动态设置,也就是说不能使用响应式来修改其值。示例如下:
|
||||
|
||||
```tsx
|
||||
// 内部渲染内容比较简单,不需要启用缓存
|
||||
<container nocache>
|
||||
<text />
|
||||
</container>
|
||||
// 路径较为复杂,因此启用 g-path 的缓存行为
|
||||
<g-path path={veryComplexPath} cache />
|
||||
```
|
||||
|
||||
### 元素溢出行为
|
||||
|
||||
溢出行为是指,当其子元素超出父元素的大小时,执行的行为。例如,假如父元素大小为 `200 * 200`,里面有一个子元素,大小为 `100 * 100`,位于 `(150, 50)` 的位置,这时候子元素的一部分就会超出父元素的范围。
|
||||
|
||||
在本渲染系统中,所有元素的默认溢出行为是裁剪,即不会显示任何溢出内容,注意调整容器的宽高。在 `nocache` 模式下,由于不受到缓存的约束,溢出内容依然会显示,不过不建议利用此特性来编写 UI,因为这种行为可能会在后续的更新中修改。
|
||||
|
||||
### 隐藏元素
|
||||
|
||||
可以使用 `hidden` 属性来隐藏元素:
|
||||
|
||||
```tsx
|
||||
const hidden = ref(false);
|
||||
// 一般使用一个响应式变量来控制隐藏行为,因为设置成常量没有任何意义
|
||||
<sprite hidden={hidden.value} />;
|
||||
```
|
||||
|
||||
### 交互属性
|
||||
|
||||
交互属性包括 `cursor` 和 `noevent`。前者描述了鼠标覆盖在当前元素上时的指针样式,参考 [CSS: cursor](https://developer.mozilla.org/zh-CN/docs/Web/CSS/cursor)。示例如下:
|
||||
|
||||
```tsx
|
||||
// 鼠标放置在该元素上时使用小手样式
|
||||
<sprite cursor="pointer" />
|
||||
```
|
||||
|
||||
`noevent` 表明当前元素将不会触发任何事件,事件将会下穿至纵深更低的元素。示例如下:
|
||||
|
||||
```tsx
|
||||
// 设置为 noevent 模式
|
||||
<sprite zIndex={100} noevent />
|
||||
// 这样的话这个 onClick 就可以正常触发了
|
||||
<sprite zIndex={10} onClick={click} />
|
||||
```
|
||||
|
||||
### 高清与抗锯齿
|
||||
|
||||
包含 `hd` `anti` `noanti` 三个属性,`hd` 表示是否启用高清,大部分元素是默认启用的,除了几个像素风为主的元素(地图渲染等);`anti` 表示手动启用画布的抗锯齿行为,一般用于默认不启用抗锯齿的元素;`noanti` 表示手动关闭元素的抗锯齿行为,优先级高于 `anti`,一般用于像素风图片展示、图标显示等,同时也有助于提高渲染性能。
|
||||
|
||||
```tsx
|
||||
// 关闭高清
|
||||
<sprite hd={false} />
|
||||
// 关闭抗锯齿
|
||||
<sprite noanti />
|
||||
// 启用地图渲染的抗锯齿
|
||||
<layer anti />
|
||||
```
|
||||
|
||||
### 元素变换属性
|
||||
|
||||
可以通过调整 `transform` 属性来修改元素的线性变换,包括平移、旋转、缩放。如果是简易的变换,可以使用 `rotate` `scale` 属性来修改旋转、缩放,使用 `loc` 来修改位置:
|
||||
|
||||
```tsx
|
||||
// 旋转 90 度,横向放缩为 1.5 倍,纵向不放缩
|
||||
<sprite rotate={Math.PI / 2} scale={[1.5, 1]} loc={[32, 32]} />
|
||||
```
|
||||
|
||||
我们没有设置锚点属性,那么需要注意旋转后,`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
|
||||
<sprite transform={finalTrans} />
|
||||
```
|
||||
|
||||
#### 常见问题排查
|
||||
|
||||
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/index.md)。下面是一个典型案例:
|
||||
|
||||
```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);
|
||||
});
|
||||
};
|
||||
|
||||
<container-custom render={render} />;
|
||||
```
|
||||
|
||||
## `text` 标签
|
||||
|
||||
`text` 标签用于显示文字,它会自动计算文字的宽高并设置为元素宽高,因此不要手动指定宽高,否则可能会引起位置错误。它新增了这些属性:
|
||||
|
||||
```ts
|
||||
interface TextProps extends BaseProps {
|
||||
/** 要渲染的文字 */
|
||||
text?: string;
|
||||
/** 文字的填充样式 */
|
||||
fillStyle?: CanvasStyle;
|
||||
/** 文字的描边样式 */
|
||||
strokeStyle?: CanvasStyle;
|
||||
/** 文字的字体 */
|
||||
font?: Font;
|
||||
/** 文字的描边粗细 */
|
||||
strokeWidth?: number;
|
||||
}
|
||||
```
|
||||
|
||||
典型案例如下:
|
||||
|
||||
```tsx
|
||||
import { Font } from '@motajs/render';
|
||||
|
||||
<text
|
||||
// 文字内容
|
||||
text="这是一段文字"
|
||||
// 文字定位,不要填写宽高,如果需要填写锚点,可以使用 anc 属性或宽高填 void 0
|
||||
loc={[32, 32]}
|
||||
// 填充样式,纯白色
|
||||
fillStyle="#fff"
|
||||
// 描边样式,红色
|
||||
strokeStyle="#d54"
|
||||
// 字体,使用大小为 24px 的默认字体
|
||||
font={new Font(24)}
|
||||
// 描边宽度 3px,默认为 2px
|
||||
strokeWidth={3}
|
||||
/>;
|
||||
```
|
||||
|
||||
## `image` 标签
|
||||
|
||||
`image` 标签允许你显示一张图片,包含一个 `image` 属性,传入图片对象(注意不是注册图片名称)。用例如下:
|
||||
|
||||
```tsx
|
||||
// 获取注册的图片
|
||||
const img = core.material.images.images['myImage.png'];
|
||||
// 显示图片
|
||||
<image image={img} />;
|
||||
```
|
||||
|
||||
## `icon` 标签
|
||||
|
||||
`icon` 标签用于显示一个图标,可以包含动画帧。它有如下参数:
|
||||
|
||||
```ts
|
||||
export interface IconProps extends BaseProps {
|
||||
/** 图标 id 或数字 */
|
||||
icon: AllNumbers | AllIds;
|
||||
/** 显示图标的第几帧 */
|
||||
frame?: number;
|
||||
/** 是否开启动画,开启后 frame 参数无效 */
|
||||
animate?: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
使用案例如下:
|
||||
|
||||
```tsx
|
||||
// 显示绿史莱姆,开启动画
|
||||
<icon icon="greenSlime" animate />
|
||||
```
|
||||
|
||||
## `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 像素
|
||||
<winskin image="winskin.png" borderSize={48} />
|
||||
```
|
||||
|
||||
## 图形标签
|
||||
|
||||
本小节讲解图形相关的标签,以下内容由 `DeepSeek R1` 模型生成并稍作修改。
|
||||
|
||||
### 通用属性说明
|
||||
|
||||
所有图形元素均支持以下核心属性:
|
||||
|
||||
| 属性分类 | 关键参数 | 说明 |
|
||||
| -------------- | ------------------------- | -------------------------------------------------------- |
|
||||
| **填充与描边** | `fill` `stroke` | 控制是否填充/描边(不同元素默认值不同) |
|
||||
| **样式控制** | `fillStyle` `strokeStyle` | 填充和描边样式,支持颜色等(如 `'#f00'`) |
|
||||
| **线型设置** | `lineWidth` `lineDash` | 线宽、虚线模式(如 `[5, 3]` 表示 5 像素实线+3 像素间隙) |
|
||||
| **高级控制** | `fillRule` `actionStroke` | 填充规则(非零/奇偶)、是否仅在描边区域响应交互 |
|
||||
|
||||
### 矩形 `<g-rect>`
|
||||
|
||||
矩形的定位直接使用 `loc` 即可,示例如下:
|
||||
|
||||
```tsx
|
||||
// 基础矩形,矩形默认仅填充模式,因此如果需要描边的话需要手动指定 fill 和 stroke 参数
|
||||
// 注意如果仅指定 stroke 参数的话,会变为仅描边形式
|
||||
<g-rect loc={[100, 100, 200, 150]} fill stroke fillStyle="#f0f" lineWidth={2} />
|
||||
```
|
||||
|
||||
### 圆形和扇形 `<g-circle>`
|
||||
|
||||
参数如下:
|
||||
|
||||
```ts
|
||||
interface CirclesProps {
|
||||
radius: number; // 半径
|
||||
start?: number; // 起始弧度(默认0)
|
||||
end?: number; // 结束弧度(默认2π)
|
||||
/**
|
||||
* 圆属性参数,可以填 `[圆心 x 坐标,圆心 y 坐标,半径,起始角度,终止角度]`,是 x, y, radius, start, end 的简写,
|
||||
* 其中半径可选,后两项要么都填,要么都不填
|
||||
*/
|
||||
circle?: CircleParams;
|
||||
}
|
||||
```
|
||||
|
||||
示例如下:
|
||||
|
||||
```tsx
|
||||
// 完整圆形
|
||||
<g-circle circle={[300, 200, 10]} fillStyle="skyblue" />
|
||||
// 扇形(60度到180度)
|
||||
<g-circle circle={[400, 300, 40, Math.PI/3, Math.PI]} />
|
||||
```
|
||||
|
||||
### 直线 `<g-line>`
|
||||
|
||||
核心参数:
|
||||
|
||||
```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
|
||||
// 普通直线
|
||||
<g-line
|
||||
line={[50, 50, 200, 150]}
|
||||
strokeStyle="red"
|
||||
lineDash={[10, 5]} // 虚线样式
|
||||
/>
|
||||
|
||||
// 带箭头的参考线
|
||||
<g-line
|
||||
// 不使用简写形式
|
||||
x1={300} y1={80}
|
||||
x2={450} y2={220}
|
||||
lineCap="round" // 端点圆形
|
||||
lineWidth={4}
|
||||
/>
|
||||
```
|
||||
|
||||
### 三次贝塞尔曲线 `<g-bezier>`
|
||||
|
||||
核心参数:
|
||||
|
||||
```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
|
||||
// 三次贝塞尔曲线
|
||||
<g-bezier
|
||||
curve={[100, 100, 150, 50, 250, 200, 300, 100]}
|
||||
strokeStyle="purple"
|
||||
lineWidth={3}
|
||||
/>;
|
||||
|
||||
// 动态路径
|
||||
const path = computed(() => [
|
||||
startX.value,
|
||||
startY.value,
|
||||
control1X.value,
|
||||
control1Y.value,
|
||||
control2X.value,
|
||||
control2Y.value,
|
||||
endX.value,
|
||||
endY.value
|
||||
]);
|
||||
<g-bezier curve={path.value} />;
|
||||
```
|
||||
|
||||
### 二次贝塞尔曲线 `<g-quad>`
|
||||
|
||||
核心参数:
|
||||
|
||||
```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
|
||||
// 二次贝塞尔曲线
|
||||
<g-bezier
|
||||
curve={[100, 100, 250, 200, 300, 100]}
|
||||
strokeStyle="purple"
|
||||
lineWidth={3}
|
||||
/>;
|
||||
|
||||
// 动态路径
|
||||
const path = computed(() => [
|
||||
startX.value,
|
||||
startY.value,
|
||||
controlX.value,
|
||||
controlY.value,
|
||||
endX.value,
|
||||
endY.value
|
||||
]);
|
||||
<g-bezier curve={path.value} />;
|
||||
```
|
||||
|
||||
### 圆角矩形 `<g-rectr>`
|
||||
|
||||
圆角矩形的核心参数与 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 的圆角矩形
|
||||
<g-rectr loc={[0, 0, 200, 200]} circle={[10]} />
|
||||
// 每个角都是横向半径为 10,纵向半径为 5 的椭圆
|
||||
<g-rectr loc={[0, 0, 200, 200]} ellipse={[10, 5]} />
|
||||
// 左上和右下是半径为 10 的圆角,左下和右上是半径为 25 的圆角
|
||||
<g-rectr loc={[0, 0, 200, 200]} circle={[10, 25]} />
|
||||
```
|
||||
|
||||
### 自定义路径 `<g-path>`
|
||||
|
||||
核心参数:
|
||||
|
||||
```ts
|
||||
interface PathProps {
|
||||
path?: Path2D; // 自定义路径对象
|
||||
}
|
||||
```
|
||||
|
||||
示例:
|
||||
|
||||
```tsx
|
||||
// 创建五角星
|
||||
const starPath = new Path2D();
|
||||
// ...路径绘制逻辑
|
||||
<g-path
|
||||
path={starPath}
|
||||
fill
|
||||
stroke
|
||||
fillStyle="orange"
|
||||
strokeStyle="#c00"
|
||||
lineWidth={2}
|
||||
/>;
|
||||
```
|
||||
|
||||
### 最佳实践建议
|
||||
|
||||
1. 交互增强:
|
||||
|
||||
```tsx
|
||||
<g-rect
|
||||
fill
|
||||
stroke
|
||||
actionStroke // 仅在描边区域响应点击
|
||||
onClick={handleSelect}
|
||||
/>
|
||||
```
|
||||
|
||||
2. 样式复用:
|
||||
|
||||
```tsx
|
||||
// 创建样式对象
|
||||
const themeStyle = {
|
||||
fillStyle: '#2c3e50',
|
||||
strokeStyle: '#ecf0f1',
|
||||
lineWidth: 2
|
||||
};
|
||||
|
||||
<g-rect fill {...themeStyle} />
|
||||
<g-circle stroke {...themeStyle} />
|
||||
```
|
||||
|
||||
## API 参考
|
||||
|
||||
参考[API 文档](../../api/motajs-render-vue/index.md),这里有更细致的 API 介绍。
|
||||
@@ -0,0 +1,53 @@
|
||||
# UI 常见问题
|
||||
|
||||
## 为什么我的 UI 不显示?
|
||||
|
||||
检查 UI 的纵深(zIndex)是否符合预期,有没有被其他元素遮挡;检查当前元素是否处于 `hidden` 状态。可以在控制台输入 `logTagTree()` 来输出当前的渲染树 `xml` 标签结构,会包含一些重要信息。
|
||||
|
||||
第二种可能性是你的元素处在了父元素范围之外,导致被裁剪掉。注意,`transform` 属性是对元素本身的变换,这也会导致元素本身的矩形范围发生变化,如果你的元素设置了缩放、旋转等,需要考虑此属性对位置的影响。
|
||||
|
||||
## 为什么我的元素 onClick 事件没办法触发?
|
||||
|
||||
可能你的元素被纵深更高的元素覆盖,导致事件无法传播至你的元素,考虑在纵深更高的元素上添加 `noevent` 标识来禁用它的事件传播。注意,一个纯透明的元素也可能会覆盖你的元素,仔细查看你的渲染树结构。
|
||||
|
||||
第二种可能性是其子元素拦截了冒泡或是其父元素拦截了捕获,检查 `e.stopPropagation` 调用情况。
|
||||
|
||||
## 我的数据更新后,为什么渲染内容没有更新?
|
||||
|
||||
你可能在使用 `sprite` 元素,然后在渲染函数里面调用了外部数据,这样的话当外部数据更新时,你的 `sprite` 元素并不会自动更新,需要手动更新。手动更新参考代码:
|
||||
|
||||
```tsx
|
||||
import { Sprite } from '@motajs/render';
|
||||
|
||||
const mySprite = ref<Sprite>();
|
||||
// 数据更新时,同时更新 sprite 元素
|
||||
watch(data, () => mySprite.value?.update());
|
||||
|
||||
// 将 mySprite 传入 ref 参数,这样当挂载完毕后就会将 mySprite.value 设置为该元素
|
||||
<sprite ref={mySprite} render={render} />;
|
||||
```
|
||||
|
||||
除此之外还可能你的数据不是响应式数据,确保你的数据经过了 `reactive` 或 `ref` 包裹。
|
||||
|
||||
## 我的 UI 很卡
|
||||
|
||||
可能使用了平铺式布局,建议使用 `Scroll` 组件或者 `Page` 组件来对平铺内容分割,从而提高渲染效率。可以参考对应的 [API 文档](../../api/user-client-modules/组件%20Scroll.md)。
|
||||
|
||||
## 玩着玩着突然黑屏了一下,然后画面就不显示了
|
||||
|
||||
你应该遇到了内存泄漏问题,当一个元素被卸载后,它应该会被销毁,但是如果没有被预期销毁,那么会导致内存泄漏,最终导致爆显存,就会导致画面黑屏一下,然后内容就会不显示。样板本身已经针对这个问题进行了处理,一般情况下不会出现问题,出现这个问题时大概率是你自己的组件或 UI 有问题。可能原因有很多,例如你声明了一个列表,当组件挂载时将元素放入列表,但是当组件卸载时,你却没有将元素移除,这时候就会导致这个元素无法正确被垃圾回收,从而引起内存泄漏。
|
||||
|
||||
关于这个问题的最佳实践:
|
||||
|
||||
- 如果你手动存储了一些元素,确保在卸载时将它们删除
|
||||
- 在删除你手动存储的元素的同时,调用它们的 `destroy` 方法,来确保可以被垃圾回收
|
||||
- 确保组件卸载时已经清空了定时器等内容
|
||||
- 如果需要每帧执行函数,请使用 `onTick` 接口,而非其他方法
|
||||
|
||||
## 为什么我的滤镜不显示?
|
||||
|
||||
很遗憾,截止目前(2.B 发布日期),IOS 依然没有支持 `CanvasRenderingContext2D` 上的 `filter` 方法,所有滤镜属性在 IOS 上将不会显示。不过,我们提供了 `Shader` 元素,它使用 `WebGL2` 接口,允许你制作自己的滤镜,如果滤镜是必要的,请考虑使用此元素,但是需要一定的图形学基础。
|
||||
|
||||
## 不同设备的显示内容会不一样吗?
|
||||
|
||||
从理论上来讲,除了上面那个问题提到的滤镜,其他的所有内容的渲染结果应该完全一致,如果出现了不一致的情况,请上报样板 bug。
|
||||
@@ -0,0 +1,15 @@
|
||||
# UI 系统未来规划
|
||||
|
||||
本章介绍一下 UI 系统的未来更新计划。
|
||||
|
||||
## 样式系统
|
||||
|
||||
提供类似于 CSS 的样式系统,不再需要在标签中指定颜色,而只需要定义样式,即可实现丰富的样式设定,包含继承功能。
|
||||
|
||||
## 布局系统
|
||||
|
||||
提供类似于 `flex` `grid` 布局的布局系统,即列表布局和网格布局,此时不需要手动指定元素坐标即可自动分配布局。
|
||||
|
||||
## 调试工具
|
||||
|
||||
提供 UI 调试工具,查看渲染树的属性等。
|
||||
@@ -0,0 +1,17 @@
|
||||
# UI 系统指南
|
||||
|
||||
2.B 提供了强大的 UI 系统,可以让你更快地做出更好看的 UI。
|
||||
|
||||
## 热重载
|
||||
|
||||
2.B 对 UI 有热重载支持,当你修改了代码时,你可以不刷新游戏画面就看到 UI 的变化,可以省去大量时间。
|
||||
|
||||
## 指南目录
|
||||
|
||||
- [UI 编写](./ui.md):教你如何从头开始编写一个 UI,包括界面显示、交互等。
|
||||
- [UI 元素](./elements.md):讲述所有常用的 UI 元素(标签),包括图片、图形、文字等。
|
||||
- [组件使用指南](./component.md):教你如何使用一些常用组件。
|
||||
- [优化性能](./perf.md):教你如何优化一个 UI 的性能表现。
|
||||
- [UI 系统](./system.md):解释 UI 系统中 UI 控制器的工作原理,重点在于 UI 的打开与关闭。
|
||||
- [常见问题](./faq.md):编写 UI 时的常见问题解答。
|
||||
- [未来规划](./future.md):UI 系统的未来规划。
|
||||
@@ -0,0 +1,142 @@
|
||||
---
|
||||
lang: zh-CN
|
||||
---
|
||||
|
||||
# UI 优化指南
|
||||
|
||||
多数情况下,我们编写的简单 UI 并不需要特别的性能优化,渲染系统的懒更新及缓存机制已经可以有很优秀的性能表现,不过我们还是可能会遇到一些需要特殊优化的场景,本节将会讲述如何优化 UI 的性能表现,优化建议包括:避免元素平铺;使用 `Scroll` 或 `Page` 组件优化平铺性能;避免元素自我更新;使用 `cache` 和 `nocache` 标识;特殊场景禁用抗锯齿和高清;在合适场景下隐藏一些元素等。
|
||||
|
||||
## 避免元素平铺
|
||||
|
||||
在不使用 `Scroll` 组件时,我们需要尽量避免元素平铺,因为这会导致更新时渲染次数上升,从而引起性能下降。我们建议善用树形结构的缓存特性,将可以作为一个整体的元素使用一个容器 `container` 包裹起来,减少更新时的渲染次数,尤其是对于那些不常更新的元素来说,更应该使用容器包裹。不过我们也不建议嵌套过深,这可能导致浪费在递归渲染上的时间过长,渲染效率变低。
|
||||
|
||||
画布渲染树的深度遍历特性使得:
|
||||
|
||||
- 每个独立容器的更新会触发子树的重新渲染
|
||||
- 容器层级过深会增加递归调用栈开销
|
||||
- 合理分组可将高频/低频更新元素隔离
|
||||
|
||||
下面是代码示例:
|
||||
|
||||
```tsx
|
||||
// ❌ 差的写法,全部平铺在一个容器里
|
||||
<container>
|
||||
<text text="1" />
|
||||
{/* 中间省略 998 个元素 */}
|
||||
<text text="1000" />
|
||||
</container>
|
||||
|
||||
// ✅ 好的写法
|
||||
<container>
|
||||
{/* 把不常更新的单独放到一个容器里面 */}
|
||||
<container>
|
||||
<text text="1" />
|
||||
{/* 中间省略 988 个元素 */}
|
||||
<text text="990" />
|
||||
</container>
|
||||
{/* 把常更新的单独放到一个容器里面 */}
|
||||
<container>
|
||||
<text text="991" />
|
||||
{/* 中间省略 8 个元素 */}
|
||||
<text text="1000" />
|
||||
</container>
|
||||
</container>
|
||||
```
|
||||
|
||||
## 使用 `Scroll` 或 `Page` 组件优化平铺性能
|
||||
|
||||
在一些特殊情况下,我们不得不使用平铺布局,例如上一节提到的怪物手册,或是展示一个列表等,这时候必须平铺元素。这时候我们可以使用 `Scroll` 组件或 `Page` 组件来优化性能表现。`Scroll` 组件中,只有在画面内的元素会被渲染,而画面外的不会被渲染,这会大大提高渲染效率;`Page` 组件允许你把列表拆分成多个部分,然后把内容放在不同页中,从而提高渲染性能。极端情况下,`Page` 组件的渲染效率要明显高于 `Scroll` 组件,但是滚动条对于交互更友好,我们推荐在简单场景下使用 `Scroll` 组件,而对于复杂场景,换为 `Page` 组件。两个组件的使用方式可以参考 [API 文档](../../api/motajs-render-elements/)。
|
||||
|
||||
我们建议:
|
||||
|
||||
1. **优先使用 Scroll**:
|
||||
- 元素数量 < 500
|
||||
- 需要流畅滚动交互
|
||||
- 元素高度不固定
|
||||
2. **切换至 Page**:
|
||||
- 元素数量 > 1000
|
||||
- 需要支持快速跳转
|
||||
- 存在复杂子组件(如嵌套动画)
|
||||
|
||||
下面是代码示例:
|
||||
|
||||
```tsx
|
||||
// ❌ 差的写法,全部平铺在一个容器里
|
||||
<container>
|
||||
<text text="1" />
|
||||
{/* 中间省略 998 个元素 */}
|
||||
<text text="1000" />
|
||||
</container>
|
||||
|
||||
// ✅ 好的写法,使用 Scroll 组件优化
|
||||
<Scroll>
|
||||
<text text="1" />
|
||||
{/* 中间省略 998 个元素 */}
|
||||
<text text="1000" />
|
||||
</Scroll>
|
||||
|
||||
// ✅ 好的写法,使用 Page 组件优化
|
||||
<Page>
|
||||
{(page: number) => {
|
||||
return list.slice(page * 10, (page + 1) * 10).map(v => <text text={v.toString()} />)
|
||||
}}
|
||||
</Page>
|
||||
```
|
||||
|
||||
## 避免元素自我更新
|
||||
|
||||
元素自我更新是指,在元素的渲染函数内,触发了元素的冒泡更新,这会导致更新无限循环,而且难以察觉。为了解决难以察觉的问题,我们使用了一种方式来专门探测这种情况。常见的触发元素自我更新的场景就是使用 `sprite` 元素,例如:
|
||||
|
||||
```tsx
|
||||
const element = ref<Sprite>();
|
||||
const render = () => {
|
||||
element.value?.update();
|
||||
};
|
||||
|
||||
<sprite render={render} ref={element} />;
|
||||
```
|
||||
|
||||
在上面这段渲染代码中,`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` `icon` `layer` `layer-group` `animation`,对于这些元素,你可以使用 `nocache` 标识来禁用它们的缓存,对于其本身或其子元素的渲染较为简单的场景,禁用缓存后渲染效率可能会更高。其他元素默认是禁用缓存的,如果你的渲染内容比较复杂,例如 `g-path` 元素的路径很复杂,可以使用 `cache` 表示来启用缓存,从而提高渲染效率。示例代码如下:
|
||||
|
||||
```tsx
|
||||
const render = (canvas: MotaOffscreenCanvas2D) => {
|
||||
canvas.ctx.fillRect(0, 0, 200, 200);
|
||||
};
|
||||
// ❌ 差的写法,一个简单的矩形绘制,但是 sprite 默认启用缓存,可能会拉低渲染效率
|
||||
<sprite render={render} />;
|
||||
|
||||
// ✅ 好的写法,使用 nocache 标识禁用 sprite 的缓存机制
|
||||
<sprite render={render} nocache />;
|
||||
```
|
||||
|
||||
## 特殊场景禁用抗锯齿和高清
|
||||
|
||||
默认情况下,大部分元素都是默认启用高清即抗锯齿的(`layer` 和 `layer-group` `icon` 不启用),这可能会导致一些不必要的计算出现,从而拉低渲染性能。对于一些需要保持像素风的内容,我们建议关闭抗锯齿和高清画布。代码示例如下:
|
||||
|
||||
```tsx
|
||||
// ❌ 差的写法,像素风图片使用默认设置,启用了抗锯齿和高清
|
||||
<image image="pixel.png" />
|
||||
// ✅ 好的写法,关闭了默认的抗锯齿和高清
|
||||
<image image="pixel.png" noanti hd={false} />
|
||||
```
|
||||
|
||||
## 在合适场景下隐藏一些元素
|
||||
|
||||
如果一个元素在某些场景下需要隐藏,另一些场景下需要显示,我们建议使用 `hidden` 属性来设置,而不是通过把它移动到画面外、调成透明颜色、使用 `if` 或三元表达式判断等方式。示例代码如下:
|
||||
|
||||
```tsx
|
||||
// ❌ 差的写法,使用条件表达式切换元素显示与否
|
||||
{
|
||||
!hidden.value && <sprite />;
|
||||
}
|
||||
// ✅ 好的写法,使用 hidden 属性
|
||||
<sprite hidden={hidden.value} />;
|
||||
```
|
||||
|
||||
## 后续计划
|
||||
|
||||
我们后续计划推出渲染树调试工具,届时可以更加细致方便地查看渲染树的渲染情况以及性能问题。
|
||||
@@ -0,0 +1,255 @@
|
||||
# UI 系统
|
||||
|
||||
本节将会讲解 2.B 的渲染树与 UI 系统的工作原理,以及一些常用 API。
|
||||
|
||||
:::info
|
||||
**这部分可以选择性阅读,只有打开和关闭较为重要,其他功能一般场景下用不到。**
|
||||
:::
|
||||
|
||||
## 打开与关闭 UI
|
||||
|
||||
在 UI 编写章节已经提到了打开和关闭 UI 使用 `open` 和 `close` 方法,现在我们更细致地讲解一下如何打开与关闭 UI。打开 UI 使用 `open` 方法,定义如下:
|
||||
|
||||
```ts
|
||||
function open<T extends UIComponent>(
|
||||
ui: IGameUI<T>,
|
||||
props: UIProps<T>,
|
||||
alwaysShow?: boolean
|
||||
): IUIInstance;
|
||||
```
|
||||
|
||||
其中第一个参数表示要打开的 UI,第二个表示传给 UI 的参数,第三个表示 UI 是否永远保持显示状态(除非被关闭),不受到显示模式的影响。同种 UI 可以打开多个,也可以在不同的控制器上同时打开多个相同的 UI。例如,如果我们想在主 UI 控制器中添加一个常量的返回游戏按钮,就可以这么写:
|
||||
|
||||
```ts
|
||||
// BackToGame 是自定义 UI,第三个参数传 true 来保证它一直显示在画面上
|
||||
mainUIController.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
|
||||
mainUIController.closeAll(EnemyInfo);
|
||||
```
|
||||
|
||||
:::warning
|
||||
以下内容属于进阶内容,没有高级需求不需要理解。
|
||||
:::
|
||||
|
||||
## 创建一个自己的 UI 管理器
|
||||
|
||||
样板提供 `UIController` 类,允许你在自己的一个 UI 中创建自己的 UI 管理器,例如在样板中,游戏画面本身包含一个 UI 管理器,分为了封面、加载界面、游戏界面三种,其中游戏界面里面还有一个游戏 UI 管理器,我们常用的就是最后一个游戏 UI 管理器。
|
||||
|
||||
多数情况下,样板自带的 UI 管理器已经足够,不需要自己创建 UI 管理器。我们最常用的管理器就是 `mainUIController`,它控制了游戏界面下的 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
|
||||
<container>{myController.render()}</container>
|
||||
```
|
||||
|
||||
## 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();
|
||||
```
|
||||
|
||||
## 渲染系统的树结构
|
||||
|
||||
接下来我们来讲解一下渲染系统的一些工作原理。下面的部分由 `DeepSeek R1` 模型生成并稍作修改。
|
||||
|
||||
### 结构原理
|
||||
|
||||
想象一棵倒着生长的树:
|
||||
|
||||
- 根节点:相当于画布本身,是所有元素的起点
|
||||
- 枝干节点:类似文件夹,可以包含其他元素
|
||||
- 叶子节点:实际显示的内容,如图片、文字等
|
||||
|
||||
### 运作特点
|
||||
|
||||
- 层级管理:子元素永远在父元素的"内部"显示
|
||||
- 自动排序:像叠扑克牌一样,后添加的元素默认盖在之前元素上方,不过也可以通过参数来调整顺序
|
||||
- 智能裁剪:父元素就像相框,超出范围的内容自动隐藏
|
||||
|
||||
## 渲染系统的事件系统
|
||||
|
||||
### 事件传递三阶段
|
||||
|
||||
1. 收件扫描(捕获阶段):从根部开始层层扫描,寻找可能接收事件的元素,类似快递分拣中心扫描包裹目的地
|
||||
2. 精准投递(目标阶段):找到实际触发事件的元素进行处理,就像快递员将包裹送到收件人手中
|
||||
3. 回执确认(冒泡阶段):处理结果沿着原路返回汇报,如同收件人签收后系统更新物流状态
|
||||
|
||||
将事件分为三个阶段,是为了让交互更加符合直觉,你也不想点击内层按钮的时候外层按钮也被触发吧)
|
||||
|
||||
### 特殊处理机制
|
||||
|
||||
- 紧急拦截:任何环节都可以标记"无需继续传递"
|
||||
- 批量处理:多个事件自动合并减少处理次数
|
||||
- 智能过滤:自动忽略不可见区域的事件
|
||||
|
||||
## 冒泡更新
|
||||
|
||||
### 工作原理
|
||||
|
||||
当某个元素发生变化时:自动通知直系父元素,父元素检查自身是否需要调整,继续向上传递直到根部,最终统一计算所有需要改变的位置,并在下一帧执行更新。
|
||||
|
||||
### 设计优势
|
||||
|
||||
- 精准定位:只更新受影响的部分画面
|
||||
- 避免重复:多个子元素变化只需一次整体计算
|
||||
- 顺序保障:始终从最深层开始逐层处理
|
||||
|
||||
## 懒更新机制
|
||||
|
||||
### 工作模式
|
||||
|
||||
1. 收集阶段:记录所有需要改变的内容(如颜色变化、文字修改)
|
||||
2. 等待时机:一般是等待到下一帧
|
||||
3. 批量处理:一次性完成所有修改
|
||||
|
||||
### 实际效益
|
||||
|
||||
- 性能优化:减少像频繁开关灯的资源浪费
|
||||
- 流畅保障:避免连续小改动导致的画面闪烁
|
||||
- 智能调度:优先处理用户可见区域的变化
|
||||
@@ -0,0 +1,852 @@
|
||||
---
|
||||
lang: zh-CN
|
||||
---
|
||||
|
||||
# UI 编写
|
||||
|
||||
本文将介绍如何在 2.B 样板中编写 UI,以及如何优化 UI 性能。
|
||||
|
||||
## 创建 UI 文件
|
||||
|
||||
首先,我们打开 `packages-user/client-modules/render` 文件夹,这里是目前样板的 UI 目录(之后可能会修改),我们可以看到 `components` `ui` 等文件夹,其中 `component` 是组件文件夹,也就是所有 UI 都可能用到的组件,例如滚动条、分页、图标等,这些东西不会单独组成一个 UI,但是可以方便 UI 开发。`ui` 就是 UI 文件夹,这里面存放了所有的 UI,我们在此文件夹下创建一个文件 `myUI.tsx`。
|
||||
|
||||
## 编写 UI 模板
|
||||
|
||||
下面,我们需要编写 UI 模板,以怪物手册为例,模板如下,直接复制粘贴即可:
|
||||
|
||||
```tsx
|
||||
import { defineComponent } from 'vue';
|
||||
import { GameUI, UIComponentProps, DefaultProps } from '@motajs/system-ui';
|
||||
import { SetupComponentOptions } from '../components';
|
||||
|
||||
export interface MyBookProps extends UIComponentProps, DefaultProps {}
|
||||
|
||||
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`,增加如下代码:
|
||||
|
||||
```ts
|
||||
export * from './myUI';
|
||||
```
|
||||
|
||||
## 添加一些内容
|
||||
|
||||
新的 UI 使用 tsx 编写,即 `TypeScript JSX`,可以直接在 ts 文件中编写 XML,非常适合编写 UI。例如,我们想要把 UI 的位置设为水平竖直居中,位置在 240, 240,长宽为 480, 480,并显示一个文字,可以这么写:
|
||||
|
||||
```tsx
|
||||
// ... 其他内容
|
||||
// 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` 这个根组件中调用 `mainUIController.open`:
|
||||
|
||||
```ts
|
||||
// 在这添加引入
|
||||
import { MyBookUI } from './ui';
|
||||
// ... 其他内容
|
||||
const MainScene = defineComponent(() => {
|
||||
// ... 其他内容
|
||||
// 在这添加一句话,打开 UI,第二个参数为传入 UI 的参数,后面会有讲解
|
||||
// 纵深设为 100 以保证可以显示出来,纵深越大,元素越靠上,会覆盖纵深低的元素
|
||||
mainUIController.open(MyBookUI, { zIndex: 100 }); // [!code ++]
|
||||
return () => (
|
||||
// ... 其他内容
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
这样的话,我们就会在页面上显示一个新的 UI 了!不过这个 UI 会是常亮的 UI,没办法关闭,我们需要更精细的控制。我们可以在内部使用 `props.controller` 来获取到 UI 控制器实例,使用 `props.instance` 获取到当前 UI 实例,从而控制当前 UI 的状态:
|
||||
|
||||
```tsx
|
||||
export const MyBook = defineComponent<MyBookProps>(props => {
|
||||
// 例如,我们可以让它在打开 10 秒钟后关闭:
|
||||
setTimeout(() => props.controller.close(props.instance), 10000); // [!code ++]
|
||||
return () => (
|
||||
// ... UI 内容
|
||||
);
|
||||
}, myBookProps);
|
||||
```
|
||||
|
||||
除此之外,我们还可以在任意渲染端模块中引入 `ui/controller` 来获取到根组件的 UI 控制器。例如,我们可以在其他文件中控制这个 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 打开与关闭的细节参考[此文档](./system.md#打开与关闭-ui)
|
||||
|
||||
更多的 UI 控制功能可以参考后续文档以及相关的 [UI 系统指南](./system.md) 或 [API 文档](../../api/motajs-system-ui/UIController)。
|
||||
|
||||
## 添加更多内容
|
||||
|
||||
既然我们要编写一个简易怪物手册,那么仅靠上面这些内容当然不够,我们需要更多的元素和组件才行,下面我们来介绍一些常用的元素及组件。
|
||||
|
||||
### 图标
|
||||
|
||||
既然是怪物手册,那么图标必然不能少,图标是 `<icon>` 元素,需要传入 `icon` 参数,例如:
|
||||
|
||||
```tsx
|
||||
return () => (
|
||||
<container>
|
||||
{/* 显示绿史莱姆图标,位置在 (32, 32),animate 表示循环播放动画 */}
|
||||
<icon icon="greenSlime" loc={[32, 32]} animate />
|
||||
</container>
|
||||
);
|
||||
```
|
||||
|
||||
### 字体
|
||||
|
||||
我们很多时候也会想要自定义字体,可以通过 `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 () => (
|
||||
<container>
|
||||
<icon icon="greenSlime" loc={[32, 32]} animate />
|
||||
<text
|
||||
text="绿史莱姆"
|
||||
// 使用上面定义的字体
|
||||
font={font}
|
||||
// 靠左对齐,上下居中对齐
|
||||
loc={[64, 48, void 0, void 0, 0, 0.5]}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
```
|
||||
|
||||
更多的字体使用方法可以参考 [API 文档](../../api/motajs-render-style/Font)
|
||||
|
||||
### 圆角矩形
|
||||
|
||||
我们可以为怪物手册的一栏添加圆角矩形,写法如下:
|
||||
|
||||
```tsx
|
||||
return () => (
|
||||
<container>
|
||||
<g-rectr
|
||||
// 圆角矩形的位置
|
||||
loc={[16, 16, 480 - 32, 480 - 32]}
|
||||
// 圆角矩形为仅描边
|
||||
stroke
|
||||
// 圆角半径,可以设置四个,具体参考圆角矩形的文档
|
||||
circle={[8]}
|
||||
// 描边样式,这里设为了金色
|
||||
strokeStyle="gold"
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
```
|
||||
|
||||
### 线段
|
||||
|
||||
我们也可以添加线段,作为怪物列表之间的分割线:
|
||||
|
||||
```tsx
|
||||
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` 组件:
|
||||
|
||||
```tsx
|
||||
// 从 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` 组件,用法如下:
|
||||
|
||||
```tsx
|
||||
// 从 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` 方法来遍历怪物列表,然后返回一个元素,组成元素数组,实现循环遍历的功能。示例如下:
|
||||
|
||||
```tsx
|
||||
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);
|
||||
```
|
||||
|
||||
### 条件判断
|
||||
|
||||
可以在表达式中使用三元表达式或者立即执行函数来实现条件判断:
|
||||
|
||||
```tsx
|
||||
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 的参数,首先我们先定义这个参数:
|
||||
|
||||
```tsx
|
||||
import { computed } from 'vue';
|
||||
|
||||
export interface MyBookProps extends UIComponentProps {
|
||||
// 定义 floorId 参数
|
||||
floorId: FloorIds;
|
||||
}
|
||||
|
||||
const myBookProps = {
|
||||
// 这里也要修改
|
||||
props: ['controller', 'instance', 'floorId']
|
||||
} satisfies SetupComponentOptions<MyBookProps>;
|
||||
```
|
||||
|
||||
然后我们需要在这个参数发生变动时修改怪物列表,可以这么写:
|
||||
|
||||
```tsx
|
||||
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);
|
||||
```
|
||||
|
||||
### 什么样的变量能使用响应式
|
||||
|
||||
其实,我们用一般的方式编写的变量或常量都是不能使用响应式的,例如这些都不行:
|
||||
|
||||
```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;
|
||||
|
||||
// 这样的话就有响应式效果了
|
||||
<text text={num2.value.toString()} />;
|
||||
```
|
||||
|
||||
对于对象类型来说,需要使用 `reactive` 函数包裹,这个函数会把对象变成深层响应式,任何一级发生更改都会触发响应式更新,例如:
|
||||
|
||||
```tsx
|
||||
const obj = reactive({ obj1: { num: 10 } });
|
||||
|
||||
// 这个就不需要使用 value 属性了,只有 ref 函数包裹的需要
|
||||
obj.obj1.num = 20;
|
||||
|
||||
// 直接调用即可,当值更改时内容也会自动更新
|
||||
<text text={obj.obj1.num.toString()} />;
|
||||
```
|
||||
|
||||
数组也可以使用 `reactive` 方法来实现响应式:
|
||||
|
||||
```tsx
|
||||
// 传入一个泛型来指定这个变量的类型,这里使用数字数组作为示例
|
||||
const array = reactive<number[]>([]);
|
||||
|
||||
// 可以使用数组自身的方法添加或修改元素
|
||||
array.push(100);
|
||||
|
||||
<container>
|
||||
{/* 直接对数组遍历,数组修改后这段内容也会自动更新 */}
|
||||
{array.map(v => (
|
||||
<text text={v.toString()} />
|
||||
))}
|
||||
</container>;
|
||||
```
|
||||
|
||||
如果对象比较大,只想让第一层变为响应式,深层的不变,可以使用 `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 属性即可
|
||||
<container onClick={click}>{/* 渲染内容 */}</container>;
|
||||
```
|
||||
|
||||
可以使用 `cursor` 属性来指定鼠标移动到该元素上时的指针样式,如下例所示,鼠标移动到这个容器上时就会变成小手的形状:
|
||||
|
||||
```tsx
|
||||
<container cursor="pointer" />
|
||||
```
|
||||
|
||||
鼠标与触摸事件的触发包括两个阶段,从根节点捕获,然后一路传递到最下层,然后从最下层冒泡,然后一路再传递回根节点,一般情况下我们使用冒泡阶段的监听即可,也就是 `onXxx`,例如 `onClick` 等,不过如果我们需要监听捕获阶段的事件,也可以使用 `onXxxCapture` 的方法来监听:
|
||||
|
||||
```tsx
|
||||
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` 方法即可阻止冒泡或捕获的继续传播:
|
||||
|
||||
```tsx
|
||||
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`,这样就成功避免了内外两个按钮同时触发的场景。
|
||||
|
||||
### 事件对象的属性
|
||||
|
||||
事件包含很多属性,它们定义如下,其中 `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<MyBookProps>(props => {
|
||||
// 第一个参数是按键实例,第二个参数是按键作用域,一般用不到
|
||||
const [key, scope] = useKey();
|
||||
|
||||
return () => <container />;
|
||||
});
|
||||
```
|
||||
|
||||
最后,实现按键操作,使用 `key.realize` 方法:
|
||||
|
||||
```tsx
|
||||
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` 圆角矩形元素作为选择框:
|
||||
|
||||
```tsx
|
||||
<Scroll>
|
||||
<g-rectr loc={[16, 16, 480 - 32, 480 - 32]} stroke strokeStyle="gold" />
|
||||
</Scroll>
|
||||
```
|
||||
|
||||
接下来,我们需要让它能够移动,当用户按下按键时,选择框会平滑移动到目标位置。这时候,我们可以使用动画接口 `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
|
||||
<Scroll>
|
||||
<g-rectr loc={rectLoc.value} stroke strokeStyle="gold" />
|
||||
</Scroll>
|
||||
```
|
||||
|
||||
### 执行动画
|
||||
|
||||
接下来,我们需要监听当前选中怪物,然后根据当前怪物来设置元素位置,使用 `watch` 监听 `selected` 变量:
|
||||
|
||||
```ts
|
||||
watch(selected, value => {
|
||||
// 使用 set 方法来动画至目标值
|
||||
rectY.set(16 + value * 80);
|
||||
});
|
||||
```
|
||||
|
||||
除此之外,我们还可以添加当鼠标移动至怪物元素上时,选择框也移动至目标,我们需要监听 `onEnter` 事件:
|
||||
|
||||
```tsx
|
||||
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` 标识,即可让交互事件不会受到此元素的影响,不过相应地,这个元素上的交互事件也将会无法触发。示例如下:
|
||||
|
||||
```tsx
|
||||
<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` 属性来获取其接口:
|
||||
|
||||
```tsx
|
||||
import { ScrollExpose } from './components';
|
||||
|
||||
const scrollExpose = ref<ScrollExpose>();
|
||||
|
||||
<Scroll ref={scrollExpose}></Scroll>;
|
||||
```
|
||||
|
||||
然后,我们可以调用其 `scrollTo` 方法来滚动至目标位置:
|
||||
|
||||
```tsx
|
||||
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 文档](../../api/motajs-render-core/RenderItem.md)。除此之外,我们还为这个自定义怪物手册添加了 `floorId` 参数,它也可以在打开 UI 时传入。如果需要打开的 UI 参数具有响应式,例如可以动态修改楼层 id,可以使用 `reactive` 方法。示例如下:
|
||||
|
||||
```ts
|
||||
import { MyBookProps, MyBookUI } from './myUI';
|
||||
|
||||
const props = reactive<MyBookProps>({
|
||||
floorId: 'MT0',
|
||||
zIndex: 100
|
||||
});
|
||||
|
||||
mainUIController.open(MyBookUI, props);
|
||||
```
|
||||
|
||||
我们可以监听状态栏更新来实时更新参数:
|
||||
|
||||
```ts
|
||||
import { hook } from '@user/data-base';
|
||||
|
||||
// 监听状态栏更新事件
|
||||
hook.on('updateStatusBar', () => {
|
||||
// 状态栏更新时,修改怪物手册的楼层为当前楼层 id
|
||||
props.floorId = core.status.floorId,
|
||||
});
|
||||
```
|
||||
|
||||
## 总结
|
||||
|
||||
通过以上的学习,你已经可以做出一个自己的怪物手册了!试着做一下吧!
|
||||
Reference in new issue
Block a user