From 230e08e8a631bf4d711961a6101feca2f10e87c5 Mon Sep 17 00:00:00 2001 From: unanmed <1319491857@qq.com> Date: Wed, 19 Feb 2025 22:58:08 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20UI=20=E6=8E=A7=E5=88=B6=E5=99=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- eslint.config.js | 3 +- src/core/render/utils.ts | 4 +- src/core/system/ui/container.tsx | 56 +++++ src/core/system/ui/controller.ts | 342 +++++++++++++++++++++++++++---- src/core/system/ui/index.ts | 4 + src/core/system/ui/instance.ts | 50 +++++ src/core/system/ui/shared.ts | 87 ++++++++ src/core/system/ui/ui.ts | 19 ++ src/data/logger.json | 3 +- src/module/render/index.tsx | 4 + 10 files changed, 523 insertions(+), 49 deletions(-) create mode 100644 src/core/system/ui/container.tsx create mode 100644 src/core/system/ui/instance.ts create mode 100644 src/core/system/ui/shared.ts create mode 100644 src/core/system/ui/ui.ts diff --git a/eslint.config.js b/eslint.config.js index 9d3237d..ee278af 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -64,7 +64,8 @@ export default tseslint.config( ], '@typescript-eslint/no-namespace': 'off', '@typescript-eslint/no-this-alias': 'off', - 'no-console': 'warn' + 'no-console': 'warn', + 'vue/multi-word-component-names': 'off' } }, eslintPluginPrettierRecommended diff --git a/src/core/render/utils.ts b/src/core/render/utils.ts index 80fe731..b0dd5db 100644 --- a/src/core/render/utils.ts +++ b/src/core/render/utils.ts @@ -14,9 +14,9 @@ export type Props< > = T extends keyof JSX.IntrinsicElements ? JSX.IntrinsicElements[T] : T extends DefineSetupFnComponent - ? InstanceType['$props'] + ? InstanceType['$props'] & InstanceType['$emits'] : T extends DefineComponent - ? InstanceType['$props'] + ? InstanceType['$props'] & InstanceType['$emits'] : unknown; export function disableViewport() { diff --git a/src/core/system/ui/container.tsx b/src/core/system/ui/container.tsx new file mode 100644 index 0000000..7768842 --- /dev/null +++ b/src/core/system/ui/container.tsx @@ -0,0 +1,56 @@ +import { computed, defineComponent } from 'vue'; +import { IUIMountable, UIBaseElementSlots, UIComponent } from './shared'; +import { SetupComponentOptions } from '@/module'; +import { ContainerProps } from '@/core/render'; + +export interface UIContainerProps { + controller: IUIMountable; +} + +const containerConfig = { + props: ['controller'] +} satisfies SetupComponentOptions; + +export const UIContainer = defineComponent(props => { + const data = props.controller; + const back = data.backIns; + const show = computed(() => data.stack.filter(v => !v.hidden)); + return () => { + return ( + + {(() => { + const b = back.value; + if (!b || !data.showBack.value || b.hidden) return; + return ( + + ); + })()} + {show.value.map(v => ( + + ))} + + ); + }; +}, containerConfig); + +export const UIRenderBase = defineComponent< + ContainerProps, + {}, + string, + UIBaseElementSlots +>((_props, { slots }) => { + return () => { + return {slots.defaults()}; + }; +}); + +export const UIDomBase = defineComponent<{}, {}, string, UIBaseElementSlots>( + (_props, { slots }) => { + return () => { + return
{slots.defaults()}
; + }; + } +); diff --git a/src/core/system/ui/controller.ts b/src/core/system/ui/controller.ts index 3b690ea..8fdb146 100644 --- a/src/core/system/ui/controller.ts +++ b/src/core/system/ui/controller.ts @@ -1,55 +1,307 @@ -import { Component, VNodeProps } from 'vue'; +import { logger } from '@/core/common/logger'; +import EventEmitter from 'eventemitter3'; +import { + IGameUI, + IKeepController, + IUIInstance, + IUIMountable, + UIBaseElement, + UIComponent +} from './shared'; +import { Props } from '@/core/render'; +import { UIInstance } from './instance'; +import { + computed, + ComputedRef, + h, + reactive, + ref, + Ref, + shallowRef, + ShallowRef +} from 'vue'; +import { UIContainer } from './container'; -export interface IUIControllerConfig { - /** - * 将一个ui挂载至目标元素时的操作 - * @param element 要挂载至的目标元素 - * @param ui 要挂载的ui对象 - */ - insert(element: Element, ui: UI): void; - - /** - * 将一个ui从目标元素上移除时的操作 - * @param element 被移除ui的父元素 - * @param ui 要被移除的ui元素 - */ - remove(element: Element, ui: UI): void; - - /** - * 创建一个新UI - * @param component UI组件 - * @param props UI传递的props - */ - createUI( - component: Component, - props?: (VNodeProps & { [key: string]: any }) | null - ): UI; +export const enum UIMode { + /** 仅显示最后一个 UI,在关闭时,只会关闭指定的 UI */ + LastOnly, + /** 显示所有非手动隐藏的 UI,在关闭时,只会关闭指定 UI */ + All, + /** 仅显示最后一个 UI,在关闭时,在此之后的所有 UI 会全部关闭 */ + LastOnlyStack, + /** 显示所有非手动隐藏的 UI,在关闭时,在此之后的所有 UI 会全部关闭 */ + AllStack, + /** 自定义 UI 显示模式 */ + Custom } -export const enum OpenOption { - Push, - Unshift -} - -export const enum CloseOption { - Splice, - Pop, - Shift -} - -export class UIController { - constructor(config: IUIControllerConfig) {} +export interface IUICustomConfig { + /** + * 打开一个新的 UI + * @param ins 要打开的 UI 实例 + * @param stack 当前的 UI 栈,还未将 UI 实例加入栈中 + */ + open(ins: IUIInstance, stack: IUIInstance[]): void; /** - * 设置当ui改变时控制器的行为 - * @param open 打开时的行为 - * @param close 关闭时的行为 + * 关闭一个 UI + * @param ins 要关闭的 UI 实例 + * @param stack 当前的 UI 栈,还未将 UI 实例移除 + * @param index 这个 UI 实例在 UI 栈中的索引 */ - setChangeMode(open: OpenOption, close: CloseOption) {} + close(ins: IUIInstance, stack: IUIInstance[], index: number): void; /** - * 将这个UI控制器挂载至容器上 - * @param container 要挂载至的容器 + * 隐藏一个 UI + * @param ins 要隐藏的 UI 实例,还未进入隐藏状态 + * @param stack 当前的 UI 栈 + * @param index 这个 UI 实例在 UI 栈中的索引 */ - mount(container: Element) {} + hide(ins: IUIInstance, stack: IUIInstance[], index: number): void; + + /** + * 显示一个 UI + * @param ins 要显示的 UI 实例,还未进入显示状态 + * @param stack 当前的 UI 栈 + * @param index 这个 UI 实例在 UI 栈中的索引 + */ + show(ins: IUIInstance, stack: IUIInstance[], index: number): void; + + /** + * 更新所有 UI 的显示,一般会在显示模式更改时调用 + * @param stack 当前的 UI 栈 + */ + update(stack: IUIInstance[]): void; +} + +interface UIControllerEvent {} + +export class UIController + extends EventEmitter + implements IUIMountable +{ + static controllers: Map = new Map(); + + /** 当前的 ui 栈 */ + readonly stack: IUIInstance[] = reactive([]); + /** UI 显示方式 */ + mode: UIMode = UIMode.LastOnlyStack; + /** 这个 UI 实例的背景,当这个 UI 处于显示模式时,会显示背景 */ + background?: IGameUI; + + /** 背景 UI 实例 */ + readonly backIns: ShallowRef | null> = shallowRef(null); + /** 当前是否显示背景 UI */ + readonly showBack: ComputedRef = computed( + () => this.userShowBack.value && this.sysShowBack.value + ); + + /** 自定义显示模式下的配置信息 */ + private config?: IUICustomConfig; + /** 是否维持背景 UI */ + private keepBack: boolean = false; + /** 用户是否显示背景 UI */ + private readonly userShowBack: Ref = ref(true); + /** 系统是否显示背景 UI */ + private readonly sysShowBack: Ref = ref(false); + + /** + * 创建一个 ui 控制器 + * @param id 这个控制器的唯一标识符 + */ + constructor( + public readonly id: string, + public readonly baseElement: UIBaseElement + ) { + super(); + if (UIController.controllers.has(id)) { + logger.warn(57, id); + } else { + UIController.controllers.set(id, this); + } + } + + /** + * 渲染这个 UI + */ + render() { + return h(UIContainer, { controller: this }); + } + + /** + * 设置背景 UI + * @param back 这个 UI 控制器的背景 UI + */ + setBackground(back: IGameUI) { + this.background = back; + } + + /** + * 隐藏背景 UI + */ + hideBackground() { + this.userShowBack.value = false; + } + + /** + * 显示背景 UI + */ + showBackground() { + this.userShowBack.value = true; + } + + /** + * 维持背景 UI,一般用于防闪烁,例如使用道具时可能在关闭道具栏后打开新 UI,这时就需要防闪烁 + */ + keep(): IKeepController { + this.keepBack = true; + + return { + safelyUnload: () => { + if (this.stack.length > 0) return; + this.sysShowBack.value = false; + this.keepBack = false; + }, + unload: () => { + this.sysShowBack.value = false; + this.keepBack = false; + } + }; + } + + /** + * 打开一个 ui + * @param ui 要打开的 ui + * @param vBind 传递给这个 ui 的响应式数据 + */ + open(ui: IGameUI, vBind: Props) { + const ins = new UIInstance(ui, vBind); + switch (this.mode) { + case UIMode.LastOnly: + case UIMode.LastOnlyStack: + this.stack.forEach(v => v.hide()); + this.stack.push(ins); + break; + case UIMode.All: + case UIMode.AllStack: + this.stack.push(ins); + break; + case UIMode.Custom: + this.config?.open(ins, this.stack); + break; + } + this.sysShowBack.value = true; + return ins; + } + + /** + * 关闭一个 ui + * @param ui 要关闭的 ui 实例 + */ + close(ui: UIInstance) { + const index = this.stack.indexOf(ui); + if (index === -1) return; + switch (this.mode) { + case UIMode.LastOnly: { + this.stack.splice(index, 1); + this.stack.forEach(v => v.hide()); + const last = this.stack.at(-1); + if (!last) break; + last.show(); + break; + } + case UIMode.LastOnlyStack: { + this.stack.splice(index); + this.stack.forEach(v => v.hide()); + const last = this.stack[index - 1]; + if (!last) break; + last.show(); + break; + } + case UIMode.All: { + this.stack.splice(index, 1); + break; + } + case UIMode.AllStack: { + this.stack.splice(index); + break; + } + case UIMode.Custom: { + this.config?.close(ui, this.stack, index); + break; + } + } + if (!this.keepBack && this.stack.length === 0) { + this.sysShowBack.value = false; + } + this.keepBack = false; + } + + hide(ins: IUIInstance): void { + const index = this.stack.indexOf(ins); + if (index === -1) return; + if (this.mode === UIMode.Custom) { + this.config?.hide(ins, this.stack, index); + } else { + ins.hide(); + } + } + + show(ins: IUIInstance): void { + const index = this.stack.indexOf(ins); + if (index === -1) return; + if (this.mode === UIMode.Custom) { + this.config?.show(ins, this.stack, index); + } else { + ins.show(); + } + } + + /** + * 设置为仅显示最后一个 UI + * @param stack 是否设置为栈模式,即删除一个 UI 后,其之后打开的 UI 是否也一并删除 + */ + lastOnly(stack: boolean = true) { + if (stack) { + this.mode = UIMode.LastOnlyStack; + } else { + this.mode = UIMode.LastOnly; + } + this.stack.forEach(v => v.hide()); + this.stack.at(-1)?.show(); + } + + /** + * 设置为显示所有 UI + * @param stack 是否设置为栈模式,即删除一个 UI 后,其之后打开的 UI 是否也一并删除 + */ + showAll(stack: boolean = false) { + if (stack) { + this.mode = UIMode.AllStack; + } else { + this.mode = UIMode.All; + } + this.stack.forEach(v => v.show()); + } + + /** + * 使用自定义的显示模式 + * @param config 自定义显示模式的配置 + */ + showCustom(config: IUICustomConfig) { + this.mode = UIMode.Custom; + this.config = config; + config.update(this.stack); + } + + /** + * 获取一个元素上的 ui 控制器 + * @param id 要获取的 ui 控制器的唯一标识符 + */ + static getController( + id: string + ): UIController | null { + const res = this.controllers.get(id) as UIController; + return res ?? null; + } } diff --git a/src/core/system/ui/index.ts b/src/core/system/ui/index.ts index 0471403..ef4870f 100644 --- a/src/core/system/ui/index.ts +++ b/src/core/system/ui/index.ts @@ -1 +1,5 @@ export * from './controller'; +export * from './ui'; +export * from './shared'; +export * from './instance'; +export * from './container'; diff --git a/src/core/system/ui/instance.ts b/src/core/system/ui/instance.ts new file mode 100644 index 0000000..ddd2468 --- /dev/null +++ b/src/core/system/ui/instance.ts @@ -0,0 +1,50 @@ +import { Props } from '@/core/render'; +import { IGameUI, IUIInstance, UIComponent } from './shared'; +import EventEmitter from 'eventemitter3'; +import { markRaw, mergeProps } from 'vue'; + +interface UIInstanceEvent { + hide: []; + show: []; + close: []; +} + +export class UIInstance + extends EventEmitter + implements IUIInstance +{ + private static counter: number = 0; + + readonly key: number = UIInstance.counter++; + readonly ui: IGameUI; + hidden: boolean = false; + + constructor( + ui: IGameUI, + public vBind: Props + ) { + super(); + this.ui = markRaw(ui); + } + + /** + * 设置这个 UI 实例的响应式数据的值 + * @param data 要设置的值 + * @param merge 是将传入的值与原先的值合并(true),还是将当前值覆盖掉原先的值(false) + */ + setVBind(data: Props, merge: boolean = true) { + if (merge) { + this.vBind = mergeProps(this.vBind, data) as Props; + } else { + this.vBind = data; + } + } + + hide(): void { + this.hidden = true; + } + + show(): void { + this.hidden = false; + } +} diff --git a/src/core/system/ui/shared.ts b/src/core/system/ui/shared.ts new file mode 100644 index 0000000..dbbb602 --- /dev/null +++ b/src/core/system/ui/shared.ts @@ -0,0 +1,87 @@ +import { Props } from '@/core/render'; +import { + DefineComponent, + DefineSetupFnComponent, + Ref, + ShallowRef, + SlotsType, + VNode +} from 'vue'; + +export type UIComponent = DefineSetupFnComponent | DefineComponent; + +export interface IGameUI { + /** 这个 UI 的名称 */ + readonly name: string; + /** 这个 UI 的组件 */ + readonly component: C; +} + +export interface IKeepController { + /** + * 安全关闭背景 UI,如果当前没有 UI 已开启,那么直接关闭,否则维持 + */ + safelyUnload(): void; + + /** + * 不论当前是否有 UI 已开启,都关闭背景 + */ + unload(): void; +} + +export type UIBaseElementSlots = SlotsType<{ + defaults: () => VNode[]; +}>; + +export type UIBaseElement = + | DefineComponent<{}, {}, string, UIBaseElementSlots> + | DefineSetupFnComponent<{}, {}, UIBaseElementSlots>; + +export interface IUIMountable { + /** 当前的 UI 栈 */ + readonly stack: IUIInstance[]; + /** 当前的背景 UI */ + readonly backIns: ShallowRef | null>; + /** 当前是否显示背景 UI */ + readonly showBack: Ref; + /** UI 控制器的根元素 */ + readonly baseElement: UIBaseElement; + + /** + * 隐藏一个 UI + * @param ins 要隐藏的 UI 实例 + */ + hide(ins: IUIInstance): void; + + /** + * 显示一个 UI + * @param ins 要显示的 UI 实例 + */ + show(ins: IUIInstance): void; + + /** + * 维持背景,直到下次所有 UI 都被关闭 + */ + keep(): IKeepController; +} + +export interface IUIInstance { + /** 这个 ui 实例的唯一 key,用于 vue */ + readonly key: number; + /** 这个 ui 实例的 ui 信息 */ + readonly ui: IGameUI; + /** 传递给这个 ui 实例的响应式数据 */ + readonly vBind: Props; + /** 当前元素是否被隐藏 */ + readonly hidden: boolean; + + /** + * 隐藏这个 ui + */ + hide(): void; + + /** + * 显示这个 ui + */ + show(): void; +} diff --git a/src/core/system/ui/ui.ts b/src/core/system/ui/ui.ts new file mode 100644 index 0000000..3f28345 --- /dev/null +++ b/src/core/system/ui/ui.ts @@ -0,0 +1,19 @@ +import { IGameUI, UIComponent } from './shared'; + +export class GameUI implements IGameUI { + static list: Map> = new Map(); + + constructor( + public readonly name: string, + public readonly component: C + ) {} + + /** + * 根据 ui 名称获取 ui 实例 + * @param id ui 的名称 + */ + static get(id: string): GameUI | null { + const ui = this.list.get(id) as GameUI; + return ui ?? null; + } +} diff --git a/src/data/logger.json b/src/data/logger.json index 0b49000..7d823d7 100644 --- a/src/data/logger.json +++ b/src/data/logger.json @@ -87,7 +87,8 @@ "53": "Cannot $1 audio route '$2', since there is not added route named it.", "54": "Missing start tag for '$1', index: $2.", "55": "Unchildable tag '$1' should follow with param.", - "56": "Method '$1' is deprecated. Consider using '$2' instead.", + "56": "Method '$1' has been deprecated. Consider using '$2' instead.", + "57": "Repeated UI controller on item '$1', new controller will not work.", "1001": "Item-detail extension needs 'floor-binder' and 'floor-damage' extension as dependency.", "1101": "Cannot add new effect to point effect instance, for there's no more reserve space for it. Please increase the max count of the instance." } diff --git a/src/module/render/index.tsx b/src/module/render/index.tsx index a216599..bdd04ae 100644 --- a/src/module/render/index.tsx +++ b/src/module/render/index.tsx @@ -17,6 +17,7 @@ import { Textbox } from './components'; import { ILayerGroupRenderExtends, ILayerRenderExtends } from '@/core/render'; import { Props } from '@/core/render'; import { WeatherController } from '../weather'; +import { UIController, UIRenderBase } from '@/core/system'; export function create() { const main = new MotaRenderer(); @@ -63,8 +64,11 @@ export function create() { weather.bind(map.value); }); + const ui = new UIController('main-ui', UIRenderBase); + return () => ( + {ui.render()}