refactor: UI 系统

This commit is contained in:
unanmed 2026-10-05 19:44:33 +08:00
parent 3e22e3e14a
commit 46cad94aff
26 changed files with 1164 additions and 451 deletions

View File

@ -4,7 +4,7 @@ import { Background, Selection } from './misc';
import { TextContent, TextContentProps } from './textbox';
import { TextAlign } from './textboxTyper';
import { Page, PageExpose } from './page';
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system';
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
// @ts-expect-error render/use.ts 按 D-54 留在实现层,本文件已移入系统层,按 D-68 暂以标注记录,不反向引用、不解耦
import { useKey } from '../use';
import { sleep } from 'mutate-animate';
@ -595,7 +595,7 @@ export const Choices = defineComponent<
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
*/
export function getConfirm(
controller: IUIMountable,
controller: IUIController,
text: string,
loc: ElementLocator,
width: number,
@ -648,7 +648,7 @@ export function getConfirm(
* @param props 额外的 props,参考 {@link ChoicesProps}
*/
export function getChoice<T extends ChoiceKey = ChoiceKey>(
controller: IUIMountable,
controller: IUIController,
choices: ChoiceItem<T>[],
loc: ElementLocator,
width: number,
@ -707,7 +707,7 @@ function getChoiceRoute(defaults: number) {
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
*/
export async function routedConfirm(
controller: IUIMountable,
controller: IUIController,
text: string,
loc: ElementLocator,
width: number,
@ -762,7 +762,7 @@ export async function routedConfirm(
* @param props 额外的 props,参考 {@link ChoicesProps}
*/
export async function routedChoices<T extends ChoiceKey>(
controller: IUIMountable,
controller: IUIController,
choices: ChoiceItem<T>[],
loc: ElementLocator,
width: number,

View File

@ -15,7 +15,7 @@ import {
import { transitionedColor, useKey } from '../use';
import { linear } from 'mutate-animate';
import { Background, Selection } from './misc';
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system';
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
import { KeyCode } from '@motajs/client-base';
export interface InputProps extends DefaultProps, Partial<TextContentProps> {
@ -544,7 +544,7 @@ export const InputBox = defineComponent<
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
*/
export function getInput(
controller: IUIMountable,
controller: IUIController,
text: string,
loc: ElementLocator,
width: number,
@ -581,7 +581,7 @@ export function getInput(
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
*/
export async function getInputNumber(
controller: IUIMountable,
controller: IUIController,
text: string,
loc: ElementLocator,
width: number,

View File

@ -10,7 +10,7 @@ import { Scroll, ScrollExpose, ScrollProps } from './scroll';
// @ts-expect-error render/use.ts 按 D-54 留在实现层,本文件已移入系统层,按 D-68 暂以标注记录,不反向引用、不解耦
import { transitioned } from '../use';
import { logger } from '@motajs/common';
import { GameUI, IUIMountable, SetupComponentOptions } from '@motajs/system';
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
import { clamp } from 'lodash-es';
// @ts-expect-error render/renderer 按 D-54 留在实现层,本文件已移入系统层,按 D-59 暂以标注记录,不反向引用、不解耦
import { using } from '../renderer';
@ -568,7 +568,7 @@ export const WaitBox = defineComponent<
* @param props 额外的 props,参考 {@link WaitBoxProps}
*/
export function waitbox<T>(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
width: number,
promise: Promise<T>,

View File

@ -1,9 +1,12 @@
import { IBGMPlayer, IMotaAudioContext, ISoundPlayer } from '@motajs/audio';
import { ISaveSystem } from './save';
import { ICoreState } from '@user/data-state';
import { ITextureManager } from './material';
import { IRenderTreeRoot } from '@motajs/render';
import { IExcitation, IExcitationDivider } from '@motajs/animate';
import { IRenderTreeRoot } from '@motajs/render';
import { IRendererUsing } from '@motajs/render-vue';
import { UIController } from '@motajs/system';
import { IBGMPlayer, IMotaAudioContext, ISoundPlayer } from '@motajs/audio';
import { ICoreState } from '@user/data-state';
import { ISaveSystem } from './save';
import { ITextureManager } from './material';
import { IMapRenderer, IMapExtensionManager } from './map';
export interface IClientBase extends ICoreState {
/** 存档系统 */
@ -22,6 +25,18 @@ export interface IClientBase extends ICoreState {
readonly rafExcitation: IExcitation<number>;
/** 用于渲染系统的激励源分频器 */
readonly excitationDivider: IExcitationDivider<number>;
/** 主地图渲染器,主要用于渲染游戏画面中的地图 */
readonly mainMapRenderer: IMapRenderer;
/** 副地图渲染器,主要用于渲染缩略图、浏览地图等内容 */
readonly expandMapRenderer: IMapRenderer;
/** 主地图渲染器的拓展管理对象 */
readonly mainMapExtension: IMapExtensionManager;
/** 渲染器使用对象,供组件内注册渲染激励与事件监听 */
readonly using: IRendererUsing;
/** 主场景的 UI 控制器,管理游戏主场景的 UI 显示 */
readonly sceneController: UIController;
/** 主界面的 UI 控制器,管理游戏主界面的 UI 显示 */
readonly mainUIController: UIController;
}
export interface IClientBaseExtended {

View File

@ -39,6 +39,8 @@ import {
import { WebLoadStarter } from '@motajs/loader';
import { IRendererUsing, RendererUsing } from '@motajs/render-vue';
import { UIController } from '@motajs/system';
import { createApp } from './render';
import { LoadSceneUI } from './render/ui/load';
export class ClientCore extends CoreState implements IClientCore {
// Layer 4 渲染基础层
@ -50,14 +52,15 @@ export class ClientCore extends CoreState implements IClientCore {
readonly excitationDivider: IExcitationDivider<number>;
readonly renderer: IRenderTreeRoot;
readonly materials: ITextureManager;
// Layer 5 渲染顶层
readonly using: IRendererUsing;
readonly mainMapRenderer: IMapRenderer;
readonly expandMapRenderer: IMapRenderer;
readonly mainMapExtension: IMapExtensionManager;
readonly sceneController: UIController;
readonly mainUIController: UIController;
// Layer 5 渲染顶层
constructor(config: IClientCoreConfig) {
super({
loadStarter: new WebLoadStarter(),
@ -91,6 +94,8 @@ export class ClientCore extends CoreState implements IClientCore {
const rafExcitation = new RafExcitation();
const excitationDivider = new ExcitationDivider<number>();
this.rafExcitation = rafExcitation;
this.excitationDivider = excitationDivider;
if (DEBUG_VARIATOR) {
const variator = new ExcitationVariator();
@ -105,8 +110,6 @@ export class ClientCore extends CoreState implements IClientCore {
excitationDivider.setDivider(DIVIDER_DEBUG_DIVIDER);
}
this.rafExcitation = rafExcitation;
this.excitationDivider = excitationDivider;
this.renderer = new MotaRenderer({
canvas: '#render-main',
width: MAIN_WIDTH,
@ -117,12 +120,20 @@ export class ClientCore extends CoreState implements IClientCore {
this.using = new RendererUsing(this.renderer);
this.mainMapRenderer = new MapRenderer(this.materials);
this.mainMapExtension = new MapExtensionManager(this.mainMapRenderer);
this.expandMapRenderer = new MapRenderer(this.materials);
this.sceneController = new UIController('main-scene');
this.mainUIController = new UIController('main-ui');
//#endregion
this.loader.addCoreConfig('client', config.clientURL);
this.init();
}
private async init() {
createApp(this.sceneController.render()).mount(this.renderer);
this.sceneController.open(LoadSceneUI, {});
}
/**

View File

@ -2,7 +2,7 @@ import { DefaultProps } from '@motajs/render-vue';
import {
GameUI,
SetupComponentOptions,
UIComponentProps,
IUIDefaultPropsBase,
UIController
} from '@motajs/system';
import { defineComponent } from 'vue';
@ -10,7 +10,8 @@ import { MAIN_HEIGHT, MAIN_WIDTH } from '../../shared';
export const mainUIController = new UIController('main-ui');
export interface MainBackgroundProps extends DefaultProps, UIComponentProps {}
export interface MainBackgroundProps
extends DefaultProps, IUIDefaultPropsBase {}
const mainBackgroundProps = {
props: ['controller', 'instance']

View File

@ -2,7 +2,7 @@ import { DefaultProps } from '@motajs/render-vue';
import {
GameUI,
SetupComponentOptions,
UIComponentProps
IUIDefaultPropsBase
} from '@motajs/system';
import { defineComponent } from 'vue';
import {
@ -26,7 +26,7 @@ import { sleep } from '@motajs/common';
import { GameTitleUI } from './title';
import { client } from '../../core';
export interface ILoadProps extends UIComponentProps, DefaultProps {}
export interface ILoadProps extends IUIDefaultPropsBase, DefaultProps {}
const loadSceneProps = {
props: ['controller', 'instance']

View File

@ -2,9 +2,9 @@ import { ElementLocator, IWheelEvent, Font } from '@motajs/render';
import { DefaultProps } from '@motajs/render-vue';
import {
GameUI,
IUIMountable,
IUIController,
SetupComponentOptions,
UIComponentProps
IUIDefaultPropsBase
} from '@motajs/system';
import {
defineComponent,
@ -37,7 +37,7 @@ export const enum SaveMode {
Other
}
export interface SaveProps extends UIComponentProps, DefaultProps {
export interface SaveProps extends IUIDefaultPropsBase, DefaultProps {
loc: ElementLocator;
mode: SaveMode;
}
@ -492,7 +492,7 @@ export type SaveValidationFunction = (
* @returns 选择的存档索引
*/
export function selectSave(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
mode: SaveMode,
validate?: SaveValidationFunction,
@ -542,7 +542,7 @@ export function selectSave(
}
export async function saveSave(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
props?: SaveProps
) {
@ -566,7 +566,7 @@ export async function saveSave(
}
export async function saveLoad(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
props?: SaveProps
) {
@ -590,7 +590,7 @@ export async function saveLoad(
}
export async function saveWithExist(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
props?: SaveProps
) {

View File

@ -1,9 +1,9 @@
import { ElementLocator } from '@motajs/render';
import {
GameUI,
IUIMountable,
IUIController,
SetupComponentOptions,
UIComponentProps
IUIDefaultPropsBase
} from '@motajs/system';
import { defineComponent } from 'vue';
import {
@ -28,7 +28,7 @@ import { useKey } from '../use';
import { client } from '../../core';
export interface MainSettingsProps
extends Partial<ChoicesProps>, UIComponentProps {
extends Partial<ChoicesProps>, IUIDefaultPropsBase {
loc: ElementLocator;
}
@ -681,7 +681,7 @@ export const ClearSaveSelectUI = new GameUI(
);
export function openSettings(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
props?: MainSettingsProps
) {
@ -692,7 +692,7 @@ export function openSettings(
}
export function openReplay(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
props?: MainSettingsProps
) {

View File

@ -1,8 +1,8 @@
import {
GameUI,
IUIMountable,
IUIController,
SetupComponentOptions,
UIComponentProps
IUIDefaultPropsBase
} from '@motajs/system';
import { defineComponent } from 'vue';
import { waitbox, ListPage, TextContent } from '@user/client-base';
@ -38,7 +38,7 @@ export interface StatisticsData {
potionGem: StatisticsDataPotionGem;
}
export interface StatisticsProps extends UIComponentProps, DefaultProps {
export interface StatisticsProps extends IUIDefaultPropsBase, DefaultProps {
data: StatisticsData;
}
@ -280,7 +280,7 @@ export function calculateStatistics(): StatisticsData {
* 打开数据统计界面
* @param controller 要在哪个 UI 控制器上打开
*/
export async function openStatistics(controller: IUIMountable) {
export async function openStatistics(controller: IUIController) {
const cal = Promise.resolve().then<StatisticsData>(() => {
return new Promise(res => {
const data = calculateStatistics();

View File

@ -2,7 +2,7 @@ import { DefaultProps } from '@motajs/render-vue';
import {
GameUI,
SetupComponentOptions,
UIComponentProps
IUIDefaultPropsBase
} from '@motajs/system';
import { defineComponent, nextTick, onMounted, ref } from 'vue';
import {
@ -57,7 +57,7 @@ interface ButtonOption {
colorTrans: ITransitionedController<string>;
}
export interface GameTitleProps extends DefaultProps, UIComponentProps {}
export interface GameTitleProps extends DefaultProps, IUIDefaultPropsBase {}
const gameTitleProps = {
props: ['controller', 'instance']

View File

@ -9,9 +9,9 @@ import {
import { BaseProps } from '@motajs/render-vue';
import {
GameUI,
IUIMountable,
IUIController,
SetupComponentOptions,
UIComponentProps
IUIDefaultPropsBase
} from '@motajs/system';
import {
computed,
@ -41,7 +41,7 @@ import {
STATUS_BAR_WIDTH
} from '../../shared';
export interface ViewMapProps extends UIComponentProps, BaseProps {
export interface ViewMapProps extends IUIDefaultPropsBase, BaseProps {
loc: ElementLocator;
floorId?: FloorIds;
}
@ -556,7 +556,7 @@ export const ViewMap = defineComponent<ViewMapProps>(props => {
export const ViewMapUI = new GameUI('view-map', ViewMap);
export function openViewMap(
controller: IUIMountable,
controller: IUIController,
loc: ElementLocator,
props?: ViewMapProps
) {

View File

@ -1,6 +1,6 @@
import { compressToBase64, decompressFromBase64 } from 'lz-string';
import { getConfirm, waitbox } from '@user/client-base';
import { IUIMountable } from '@motajs/system';
import { IUIController } from '@motajs/system';
import { SyncSaveFromServerResponse } from '@motajs/client-base';
import { CENTER_LOC, POP_BOX_WIDTH } from '../../shared';
@ -122,7 +122,7 @@ async function syncLoad(id: string, password: string) {
}
export async function syncFromServer(
controller: IUIMountable,
controller: IUIController,
identifier: string
): Promise<void> {
if (!/^\d{6}\w{4}$/.test(identifier) && !/^\d{4}\w{3}$/.test(identifier)) {

View File

@ -1,7 +1,4 @@
import { IClientSystem } from '@user/client-system';
import { IMapExtensionManager, IMapRenderer } from '@user/client-base';
import { IRendererUsing } from '@motajs/render-vue';
import { UIController } from '@motajs/system';
export interface IClientCoreConfig {
/** 渲染端数据配置文件路径,相对于 `src/content` */
@ -12,17 +9,4 @@ export interface IClientCoreConfig {
readonly tilesetUnit: number;
}
export interface IClientCore extends IClientSystem {
/** 主地图渲染器,主要用于渲染游戏画面中的地图 */
readonly mainMapRenderer: IMapRenderer;
/** 副地图渲染器,主要用于渲染缩略图、浏览地图等内容 */
// readonly expandMapRenderer: IMapRenderer;
/** 主地图渲染器的拓展管理对象 */
readonly mainMapExtension: IMapExtensionManager;
/** 渲染器使用对象,供组件内注册渲染激励与事件监听 */
readonly using: IRendererUsing;
/** 主场景的 UI 控制器,管理游戏主场景的 UI 显示 */
readonly sceneController: UIController;
/** 主界面的 UI 控制器,管理游戏主界面的 UI 显示 */
readonly mainUIController: UIController;
}
export interface IClientCore extends IClientSystem {}

View File

@ -0,0 +1,21 @@
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { defineComponent } from 'vue';
import { MainBackgroundProps } from '../render';
import { MAIN_WIDTH, MAIN_HEIGHT } from '../shared';
const mainBackgroundProps = {
props: ['controller', 'instance']
} satisfies SetupComponentOptions<MainBackgroundProps>;
export const MainBackground = defineComponent<MainBackgroundProps>(() => {
return () => (
<g-rect
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
fill
fillStyle="rgba(0, 0, 0, 0.8)"
/>
);
}, mainBackgroundProps);
/** 主 UI 控制器的背景 UI */
export const MainBackgroundUI = new GameUI('main-background', MainBackground);

View File

@ -0,0 +1 @@
export * from './types';

View File

@ -0,0 +1,157 @@
import { DefaultProps } from '@motajs/render-vue';
import { GameUI, SetupComponentOptions } from '@motajs/system';
import { defineComponent } from 'vue';
import {
FULL_LOC,
LOAD_BYTE_HEIGHT,
LOAD_BYTE_LENGTH,
LOAD_BYTE_LINE_WIDTH,
LOAD_FONT_COLOR,
LOAD_LOADED_COLOR,
LOAD_TASK_CENTER_HEIGHT,
LOAD_TASK_LINE_WIDTH,
LOAD_TASK_RADIUS,
LOAD_UNLOADED_COLOR,
MAIN_WIDTH
} from '../shared';
import { ElementLocator, Font, MotaOffscreenCanvas2D } from '@motajs/render';
import { transitioned } from '../render';
import { cosh, CurveMode, linear } from '@motajs/animate';
import { clamp } from 'lodash-es';
import { sleep } from '@motajs/common';
import { GameTitleUI } from './title';
import { IUIDefaultPropsBase } from './types';
export interface ILoadProps extends IUIDefaultPropsBase, DefaultProps {}
const loadSceneProps = {
props: ['controller', 'instance', 'state']
} satisfies SetupComponentOptions<ILoadProps>;
export const LoadScene = defineComponent<ILoadProps>(props => {
const loader = props.state.loader;
const taskFont = new Font('Verdana', 24);
const byteFont = new Font('Verdana', 12);
/** 当前加载进度 */
const taskProgress = transitioned(0, 500, cosh(2, CurveMode.EaseOut))!;
const byteProgress = transitioned(0, 500, cosh(2, CurveMode.EaseOut))!;
const alpha = transitioned(1, 400, linear())!;
// 两个进度条的位置
const taskLoc: ElementLocator = [
MAIN_WIDTH / 2,
LOAD_TASK_CENTER_HEIGHT,
LOAD_TASK_RADIUS * 2 + LOAD_TASK_LINE_WIDTH * 2,
LOAD_TASK_RADIUS * 2 + LOAD_TASK_LINE_WIDTH * 2,
0.5,
0.5
];
const byteLoc: ElementLocator = [
MAIN_WIDTH / 2,
LOAD_BYTE_HEIGHT,
LOAD_BYTE_LENGTH + LOAD_BYTE_LINE_WIDTH,
LOAD_BYTE_LINE_WIDTH * 2 + byteFont.size,
0.5,
0.5
];
const loadEnd = async () => {
alpha.set(0);
await sleep(400);
props.controller.closeAll();
props.controller.open(GameTitleUI, {});
};
const startLoad = async () => {
for await (const _ of loader.start()) {
taskProgress.set(loader.manager.getLoadedTasks());
byteProgress.set(loader.manager.getLoadedByte());
}
loadEnd();
};
// 开始加载
startLoad();
/** 渲染加载任务进度 */
const renderTaskList = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.lineCap = 'round';
ctx.lineWidth = LOAD_TASK_LINE_WIDTH;
ctx.font = taskFont.string();
const loaded = loader.manager.getLoadedTasks();
const total = loader.manager.getAddedTasks();
// 这里使用渐变参数,因为要有动画效果
const progress = clamp(taskProgress.value / total, 0, 1);
const cx = taskLoc[2]! / 2;
const cy = taskLoc[3]! / 2;
ctx.beginPath();
ctx.arc(cx, cy, LOAD_TASK_RADIUS, 0, Math.PI * 2);
ctx.strokeStyle = LOAD_UNLOADED_COLOR;
ctx.stroke();
ctx.beginPath();
const end = progress * Math.PI * 2 - Math.PI / 2;
ctx.arc(cx, cy, LOAD_TASK_RADIUS, -Math.PI / 2, end);
ctx.strokeStyle = LOAD_LOADED_COLOR;
ctx.stroke();
ctx.fillStyle = LOAD_FONT_COLOR;
ctx.textAlign = 'center';
ctx.textBaseline = 'middle';
ctx.fillText(`${loaded} / ${total}`, cx, cy + 3);
};
/** 渲染加载字节进度 */
const renderByteList = (canvas: MotaOffscreenCanvas2D) => {
const ctx = canvas.ctx;
ctx.lineCap = 'round';
ctx.lineWidth = LOAD_BYTE_LINE_WIDTH;
ctx.font = byteFont.string();
const total = loader.manager.getTotalByte();
const loaded = loader.manager.getLoadedByte();
// 这里使用渐变参数,因为要有动画效果
const progress = clamp(byteProgress.value / total, 0, 1);
const sx = LOAD_BYTE_LINE_WIDTH;
const sy = byteFont.size + LOAD_BYTE_LINE_WIDTH;
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + LOAD_BYTE_LENGTH, sy);
ctx.strokeStyle = LOAD_UNLOADED_COLOR;
ctx.stroke();
ctx.beginPath();
ctx.moveTo(sx, sy);
ctx.lineTo(sx + progress * LOAD_BYTE_LENGTH, sy);
ctx.strokeStyle = LOAD_LOADED_COLOR;
ctx.stroke();
ctx.textAlign = 'right';
ctx.textBaseline = 'bottom';
ctx.fillStyle = LOAD_FONT_COLOR;
const loadedMB = (loaded / 2 ** 20).toFixed(2);
const totalMB = (total / 2 ** 20).toFixed(2);
const percent = loader.manager.getByteRatio() * 100;
ctx.fillText(
`${loadedMB}MB / ${totalMB}MB | ${percent.toFixed(2)}%`,
byteLoc[2]! - LOAD_BYTE_LINE_WIDTH,
byteLoc[3]! - LOAD_BYTE_LINE_WIDTH * 2
);
};
return () => (
<container loc={FULL_LOC} alpha={alpha.ref.value}>
<custom
loc={taskLoc}
render={renderTaskList}
bindings={[taskProgress.ref]}
nocache
/>
<custom
loc={byteLoc}
render={renderByteList}
bindings={[byteProgress.ref]}
nocache
/>
</container>
);
}, loadSceneProps);
export const LoadSceneUI = new GameUI('load-scene', LoadScene);

View File

@ -0,0 +1,484 @@
import { DefaultProps } from '@motajs/render-vue';
import {
GameUI,
SetupComponentOptions,
IUIDefaultPropsBase
} from '@motajs/system';
import { defineComponent, nextTick, onMounted, ref } from 'vue';
import {
BUTTONS_HEIGHT,
BUTTONS_WIDTH,
BUTTONS_X,
BUTTONS_Y,
HALF_HEIGHT,
HALF_WIDTH,
MAIN_HEIGHT,
MAIN_WIDTH,
TITLE_BACKGROUND_IMAGE,
TITLE_FILL,
TITLE_STROKE,
TITLE_STROKE_WIDTH,
TITLE_X,
TITLE_Y
} from '../shared';
import { ElementLocator, Font } from '@motajs/render';
import {
ITransitionedController,
transitioned,
transitionedColor,
useKey
} from '../render';
import { ExitFullscreen, Fullscreen, SoundVolume } from '@user/client-base';
import { mainSetting, triggerFullscreen } from '@motajs/legacy-ui';
import { saveLoad } from './save';
import { MainSceneUI } from './main';
import { adjustCover } from '@user/client-base';
import { cosh, CurveMode, linear } from '@motajs/animate';
import { sleep } from '@motajs/common';
import { IUIDefaultPropsBase } from './types';
const enum TitleButton {
StartGame,
LoadGame,
Replay
}
interface ButtonItem {
code: TitleButton;
name: string;
color: string;
}
interface ButtonOption {
code: number;
color: string;
name: string;
hard: string;
colorTrans: ITransitionedController<string>;
}
export interface GameTitleProps extends DefaultProps, IUIDefaultPropsBase {}
const gameTitleProps = {
props: ['controller', 'instance', 'state']
} satisfies SetupComponentOptions<GameTitleProps>;
export const GameTitle = defineComponent<GameTitleProps>(props => {
const materials = props.state.materials;
const bg = materials.getImageByAlias(TITLE_BACKGROUND_IMAGE);
//#region 计算背景图
const [width, height] = adjustCover(
bg?.width ?? MAIN_WIDTH,
bg?.height ?? MAIN_HEIGHT,
MAIN_WIDTH,
MAIN_HEIGHT
);
//#region 标题设置
/** 当前是否全屏 */
const fullscreen = ref(!!document.fullscreenElement);
/** 是否开启了声音 */
const soundOpened = ref(true);
/** 是否在选择难度界面 */
const selectHard = ref(false);
/** 开始界面按钮定义,你可以在这新增自己的按钮 */
const buttonItems: ButtonItem[] = [
{
code: TitleButton.StartGame,
color: 'rgb(40, 194, 255)',
name: '开始游戏'
},
{
code: TitleButton.LoadGame,
color: 'rgb(0, 255, 55)',
name: '读取存档'
},
{
code: TitleButton.Replay,
color: 'rgb(255, 251, 0)',
name: '录像回放'
}
];
/** 开始界面按钮 */
const buttons = buttonItems.map<ButtonOption>(v => {
return {
code: v.code,
color: v.color,
name: v.name,
hard: '',
colorTrans: transitionedColor(
'#fff',
400,
cosh(2, CurveMode.EaseOut)
)!,
scale: transitioned(1, 400, cosh(2, CurveMode.EaseOut))!
};
});
/** 选择难度界面按钮 */
const hard = main.levelChoose.map<ButtonOption>(v => {
return {
code: v.hard,
color: core.arrayToRGBA(v.color!),
name: v.title,
hard: v.name,
colorTrans: transitionedColor(
'#fff',
400,
cosh(2, CurveMode.EaseOut)
)!,
scale: transitioned(1, 400, cosh(2, CurveMode.EaseOut))!
};
});
// 返回按钮
hard.push({
code: -1,
color: '#aaa',
name: '返回',
hard: '',
colorTrans: transitionedColor('#fff', 400, cosh(2, CurveMode.EaseOut))!
});
/** 声音设置按钮的颜色 */
const soundColor = transitionedColor(
'#ddd',
400,
cosh(2, CurveMode.EaseOut)
)!;
/** 开始界面按钮的不透明度,选择难度界面的不透明度使用 `1-buttonsAlpha` 计算 */
const buttonsAlpha = transitioned(1, 300, linear())!;
/** 开始界面的不透明度 */
const mainAlpha = transitioned(0, 600, linear())!;
const buttonFilter = `
drop-shadow(3px 3px 5px rgba(0, 0, 0, 0.4))
drop-shadow(0px 0px 2px rgba(255, 255, 255, 0.5))
`;
const titleFont = Font.defaults({ size: 72 });
const buttonFont = Font.defaults({ size: 24, weight: 600 });
/** 开始界面按钮的高度 */
const buttonHeight = (buttons.length - 1) * 40 + 60;
/** 选择难度界面按钮的高度 */
const hardHeight = (hard.length - 1) * 40 + 60;
/** 按钮的背景框高度 */
const rectHeight = transitioned(
buttonHeight,
600,
cosh(2, CurveMode.EaseOut)
)!;
//#region 按钮功能
/**
* 在开始界面和选择难度界面切换
*/
const toggleHard = async () => {
if (selectHard.value) {
// 当前在难度界面,将要切换至开始界面
enterMain(0);
rectHeight.set(buttonHeight);
} else {
// 当前在开始界面,将要切换至难度界面
enterHard(0);
rectHeight.set(hardHeight);
}
buttonsAlpha.set(0);
await sleep(300);
selectHard.value = !selectHard.value;
buttonsAlpha.set(1);
};
/**
* 点击读取存档按钮
*/
const loadGame = async () => {
const loc: ElementLocator = [0, 0, MAIN_WIDTH, MAIN_HEIGHT];
const success = await saveLoad(props.controller, loc);
if (success) {
props.controller.close(props.instance);
props.controller.open(MainSceneUI, {});
}
};
/**
* 点击录像回放按钮
*/
const replay = () => {
core.chooseReplayFile();
};
/**
* 选择难度并开始游戏
* @param hard 选择的难度
*/
const startGame = async (hard: string) => {
mainAlpha.set(0);
await sleep(600);
props.controller.close(props.instance);
props.controller.open(MainSceneUI, {});
nextTick(() => {
core.startGame(hard);
});
};
/**
* 点击按钮时触发
* @param code 选中的按钮的代码
*/
const clickButton = (code: number, index: number) => {
if (selectHard.value) {
if (index === hard.length - 1) {
// 选中了最后一个按钮
toggleHard();
return;
}
const item = hard[index];
startGame(item.name);
} else {
switch (code) {
case TitleButton.StartGame:
toggleHard();
break;
case TitleButton.LoadGame:
loadGame();
break;
case TitleButton.Replay:
replay();
break;
}
}
};
//#region 键盘操作
const selected = ref(0);
const [key] = useKey();
key.realize(
'@start_up',
() => {
selected.value--;
if (selected.value < 0) {
selected.value = 0;
}
if (selectHard.value) {
enterHard(selected.value);
} else {
enterMain(selected.value);
}
},
{ type: 'down' }
)
.realize(
'@start_down',
() => {
selected.value++;
if (selectHard.value) {
if (selected.value > hard.length - 1) {
selected.value = hard.length - 1;
}
enterHard(selected.value);
} else {
if (selected.value > buttons.length - 1) {
selected.value = buttons.length - 1;
}
enterMain(selected.value);
}
},
{ type: 'down' }
)
.realize('confirm', () => {
if (selectHard.value) {
clickButton(hard[selected.value].code, selected.value);
} else {
clickButton(buttons[selected.value].code, selected.value);
}
});
//#region 鼠标操作
soundOpened.value = mainSetting.getValue('audio.bgmEnabled', true);
if (soundOpened.value) {
soundColor.set('#ddd');
} else {
soundColor.set('#d22');
}
const enterMain = (index: number) => {
buttons.forEach((v, i) => {
if (index !== i) {
v.colorTrans.set('#fff');
} else {
v.colorTrans.set(v.color);
}
});
selected.value = index;
};
const enterHard = (index: number) => {
hard.forEach((v, i) => {
if (index !== i) {
v.colorTrans.set('#fff');
} else {
v.colorTrans.set(v.color);
}
});
selected.value = index;
};
const toggleSound = () => {
soundOpened.value = !soundOpened.value;
mainSetting.setValue('audio.bgmEnabled', soundOpened.value);
if (soundOpened.value) {
soundColor.set('#ddd');
} else {
soundColor.set('#d22');
}
};
const toggleFullscreen = async () => {
await triggerFullscreen(!fullscreen.value);
fullscreen.value = !!document.fullscreenElement;
};
onMounted(() => {
enterMain(0);
mainAlpha.set(1);
});
return () => (
<container
loc={[0, 0, MAIN_WIDTH, MAIN_HEIGHT]}
alpha={mainAlpha.ref.value}
>
<image
image={bg}
loc={[HALF_WIDTH, HALF_HEIGHT, width, height]}
anc={[0.5, 0.5]}
zIndex={0}
/>
<text
text={core.firstData.title}
loc={[TITLE_X, TITLE_Y]}
anc={[0.5, 0.5]}
fillStyle={TITLE_FILL}
strokeStyle={TITLE_STROKE}
font={titleFont}
strokeWidth={TITLE_STROKE_WIDTH}
/>
<container
zIndex={15}
loc={[BUTTONS_X, BUTTONS_Y, BUTTONS_WIDTH, BUTTONS_HEIGHT]}
anc={[0.5, 0]}
>
<g-rectr
loc={[2, BUTTONS_HEIGHT / 2]}
width={BUTTONS_WIDTH - 4}
height={rectHeight.ref.value - 4}
anc={[0, 0.5]}
circle={[16]}
fill
stroke
fillStyle="rgba(50, 54, 159, 0.7)"
strokeStyle="rgba(255, 204, 0, 1)"
lineWidth={3}
zIndex={0}
/>
<container
hidden={selectHard.value}
loc={[0, BUTTONS_HEIGHT / 2, BUTTONS_WIDTH, buttonHeight]}
anc={[0, 0.5]}
alpha={buttonsAlpha.ref.value}
zIndex={5}
>
{buttons.map((v, i) => {
const x = BUTTONS_WIDTH / 2;
const y = 30 + i * 40;
return (
<text
text={v.name}
font={buttonFont}
loc={[x, y, void 0, void 0, 0.5, 0.5]}
cursor="pointer"
filter={buttonFilter}
fillStyle={v.colorTrans.ref.value}
onEnter={() => enterMain(i)}
onClick={() => clickButton(v.code, i)}
/>
);
})}
</container>
<container
hidden={!selectHard.value}
loc={[0, BUTTONS_HEIGHT / 2, BUTTONS_WIDTH, hardHeight]}
anc={[0, 0.5]}
alpha={buttonsAlpha.ref.value}
>
{hard.map((v, i) => {
const x = BUTTONS_WIDTH / 2;
const y = 30 + i * 40;
return (
<text
text={v.name}
font={buttonFont}
loc={[x, y, void 0, void 0, 0.5, 0.5]}
cursor="pointer"
filter={buttonFilter}
fillStyle={v.colorTrans.ref.value}
onEnter={() => enterHard(i)}
onClick={() => clickButton(v.code, i)}
/>
);
})}
</container>
</container>
<container
zIndex={15}
loc={[MAIN_WIDTH - 20, MAIN_HEIGHT - 20, 120, 40, 1, 1]}
>
<g-rectr
loc={[0, 0, 120, 40]}
circle={[20]}
fillStyle="rgba(0, 0, 0, 0.72)"
/>
<SoundVolume
loc={[20, 0, 40, 40]}
cursor="pointer"
strokeStyle={soundColor.ref.value}
onClick={toggleSound}
/>
{!fullscreen.value ? (
<Fullscreen
loc={[60, 0, 40, 40]}
onClick={toggleFullscreen}
cursor="pointer"
/>
) : (
<ExitFullscreen
loc={[60, 0, 40, 40]}
onClick={toggleFullscreen}
cursor="pointer"
/>
)}
<g-line
line={[25, 35, 55, 5]}
strokeStyle="gray"
lineWidth={3}
lineCap="round"
zIndex={5}
noevent
hidden={soundOpened.value}
/>
</container>
</container>
);
}, gameTitleProps);
export const GameTitleUI = new GameUI('game-title', GameTitle);

View File

@ -0,0 +1,5 @@
import { IUIDefaultPropsBase } from '@motajs/system';
import { IClientBaseExtended } from '@user/client-base';
export interface IUIPropsBase
extends IUIDefaultPropsBase, IClientBaseExtended {}

View File

@ -1,22 +1,23 @@
import { defineComponent, VNode } from 'vue';
import { IUIMountable } from './shared';
import { IUIController } from './types';
import { SetupComponentOptions } from './types';
export interface UIContainerProps {
controller: IUIMountable;
export interface IUIContainerProps {
/** 当前 UI 容器组件所属的控制器 */
readonly controller: IUIController<any>;
}
const containerConfig = {
props: ['controller']
} satisfies SetupComponentOptions<UIContainerProps>;
} satisfies SetupComponentOptions<IUIContainerProps>;
export const UIContainer = defineComponent<UIContainerProps>(props => {
export const UIContainer = defineComponent<IUIContainerProps>(props => {
const data = props.controller;
const back = data.backIns;
return (): VNode[] => {
const elements: VNode[] = [];
const b = back.value;
if (b && data.showBack.value && !b.hidden) {
if (data.showBack.value && b && !b.hidden) {
elements.push(
<b.ui.component
{...b.vBind}
@ -35,7 +36,7 @@ export const UIContainer = defineComponent<UIContainerProps>(props => {
key={v.key}
controller={data}
instance={v}
hidden={v.hidden && !v.alwaysShow}
hidden={v.hidden.value}
zIndex={i * 5}
></v.ui.component>
))

View File

@ -1,158 +1,89 @@
import { logger } from '@motajs/common';
import EventEmitter from 'eventemitter3';
import {
IGameUI,
IKeepController,
IUIInstance,
IUIMountable,
IUIController,
UIComponent,
UIProps
} from './shared';
IUIDefaultPropsBase,
IUICustomStrategy,
UIRawProps
} from './types';
import { UIInstance } from './instance';
import {
computed,
ComputedRef,
h,
reactive,
ref,
Ref,
shallowReactive,
shallowRef,
ShallowRef,
VNode
} from 'vue';
import { UIContainer } from './container';
export const enum UIMode {
/** 仅显示最后一个非 alwaysShow 的 UI,在关闭时,只会关闭指定的 UI */
LastOnly,
/** 显示所有非手动隐藏的 UI,在关闭时,只会关闭指定 UI */
All,
/** 仅显示最后一个非 alwaysShow 的 UI,在关闭时,在此之后的所有 UI 会全部关闭 */
LastOnlyStack,
/** 显示所有非手动隐藏的 UI,在关闭时,在此之后的所有 UI 会全部关闭 */
AllStack,
/** 自定义 UI 显示模式 */
Custom
}
export class UIController<
PB extends IUIDefaultPropsBase<any>
> implements IUIController<PB> {
readonly stack: IUIInstance<UIComponent, PB>[] = shallowReactive([]);
readonly backIns: ShallowRef<IUIInstance<UIComponent, PB> | null>;
export interface IUICustomConfig {
/**
* 打开一个新的 UI
* @param ins 要打开的 UI 实例
* @param stack 当前的 UI 栈,还未将 UI 实例加入栈中
*/
open(ins: IUIInstance, stack: IUIInstance[]): void;
background: IGameUI | null = null;
active: Ref<boolean> = ref(false);
/**
* 关闭一个 UI
* @param ins 要关闭的 UI 实例
* @param stack 当前的 UI 栈,还未将 UI 实例移除
* @param index 这个 UI 实例在 UI 栈中的索引
*/
close(ins: IUIInstance, stack: IUIInstance[], index: number): void;
readonly showBack: Ref<boolean>;
/**
* 隐藏一个 UI
* @param ins 要隐藏的 UI 实例,还未进入隐藏状态
* @param stack 当前的 UI 栈
* @param index 这个 UI 实例在 UI 栈中的索引
*/
hide(ins: IUIInstance, stack: IUIInstance[], index: number): void;
/** 当前的自定义 UI 控制策略 */
private strategy: IUICustomStrategy<PB>;
/**
* 显示一个 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 {
open: [ui: IGameUI, ins: IUIInstance];
close: [ui: IUIInstance];
}
export class UIController
extends EventEmitter<UIControllerEvent>
implements IUIMountable
{
static controllers: Map<string, UIController> = new Map();
/** 当前的 ui 栈 */
readonly stack: IUIInstance[] = reactive([]);
/** UI 显示方式 */
mode: UIMode = UIMode.LastOnlyStack;
/** 这个 UI 实例的背景,当这个 UI 处于显示模式时,会显示背景 */
background?: IGameUI;
/** 背景 UI 实例 */
readonly backIns: ShallowRef<IUIInstance | null> = shallowRef(null);
/** 当前是否显示背景 UI */
readonly showBack: ComputedRef<boolean> = computed(
() => this.userShowBack.value && this.sysShowBack.value
);
/** 当前 UI 是否激活 */
readonly active: Ref<boolean> = ref(false);
/** 自定义显示模式下的配置信息 */
private config?: IUICustomConfig;
/** 是否维持背景 UI */
private keepBack: boolean = false;
/** 用户是否显示背景 UI */
private readonly userShowBack: Ref<boolean> = ref(true);
private readonly userShowBack: Ref<boolean>;
/** 系统是否显示背景 UI */
private readonly sysShowBack: Ref<boolean> = ref(false);
private readonly sysShowBack: Ref<boolean>;
/**
* 创建一个 ui 控制器
* @param id 这个控制器的唯一标识符
*/
constructor(public readonly id: string) {
super();
if (UIController.controllers.has(id)) {
logger.warn(57, id);
constructor() {
this.backIns = shallowRef(null);
this.userShowBack = ref(true);
this.sysShowBack = ref(true);
this.showBack = computed(
() => this.userShowBack.value && this.sysShowBack.value
);
this.strategy = new UILastOnlyStrategy();
}
setBackground<C extends UIComponent>(
background: IGameUI<C> | null,
vBind: UIRawProps<C, PB>
): IUIInstance<C, PB> | null {
if (!background) {
this.background = null;
this.backIns.value = null;
return null;
} else {
UIController.controllers.set(id, this);
this.background = background;
const ins = new UIInstance(background, vBind, true);
this.backIns.value = ins;
return ins;
}
}
hideBackground() {
this.userShowBack.value = false;
}
showBackground() {
this.userShowBack.value = true;
}
showingBack(): boolean {
return this.showBack.value;
}
render(): VNode {
return h(UIContainer, { controller: this });
}
/**
* 设置背景 UI
* @param back 这个 UI 控制器的背景 UI
*/
setBackground<T extends UIComponent>(back: IGameUI<T>, vBind: UIProps<T>) {
this.background = back;
this.backIns.value = new UIInstance(back, vBind, true);
}
/**
* 隐藏背景 UI
*/
hideBackground() {
this.userShowBack.value = false;
}
/**
* 显示背景 UI
*/
showBackground() {
this.userShowBack.value = true;
}
/**
* 维持背景 UI,一般用于防闪烁,例如使用道具时可能在关闭道具栏后打开新 UI,这时就需要防闪烁
*/
keep(): IKeepController {
this.keepBack = true;
@ -169,70 +100,29 @@ export class UIController
};
}
open<T extends UIComponent>(
ui: IGameUI<T>,
vBind: UIProps<T>,
open<C extends UIComponent>(
ui: IGameUI<C>,
vBind: UIRawProps<C, PB>,
alwaysShow: boolean = false
): IUIInstance<T> {
): IUIInstance<C, PB> {
const ins = new UIInstance(ui, vBind, alwaysShow);
switch (this.mode) {
case UIMode.LastOnly:
case UIMode.LastOnlyStack:
this.stack.push(ins);
this.stack.forEach(v => v.hide());
this.stack.findLast(v => !v.alwaysShow)?.show();
break;
case UIMode.All:
case UIMode.AllStack:
this.stack.push(ins);
break;
case UIMode.Custom:
this.config?.open(ins, this.stack);
break;
}
this.strategy.open(ins, this.stack);
this.sysShowBack.value = true;
this.active.value = true;
this.emit('open', ui, ins);
return ins;
}
close(ui: IUIInstance) {
close(ui: IUIInstance<UIComponent, PB>) {
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());
this.stack.findLast(v => !v.alwaysShow)?.show();
break;
}
case UIMode.LastOnlyStack: {
this.stack.splice(index);
this.stack.forEach(v => v.hide());
this.stack.findLast(v => !v.alwaysShow)?.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;
}
}
this.strategy.close(ui, this.stack, index);
if (!this.keepBack && this.stack.length === 0) {
this.sysShowBack.value = false;
this.active.value = false;
}
this.keepBack = false;
this.emit('close', ui);
}
closeAll(ui?: IGameUI): void {
@ -245,69 +135,113 @@ export class UIController
}
}
hide(ins: IUIInstance): void {
hide(ins: IUIInstance<UIComponent, PB>): 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();
}
this.strategy.hide(ins, this.stack, index);
}
show(ins: IUIInstance): void {
show(ins: IUIInstance<UIComponent, PB>): 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();
}
this.strategy.show(ins, this.stack, index);
}
/**
* 设置为仅显示最后一个 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();
this.showCustom(new UILastOnlyStrategy(stack));
}
/**
* 设置为显示所有 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());
this.showCustom(new UIShowAllStrategy(stack));
}
/**
* 使用自定义的显示模式
* @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);
return res ?? null;
showCustom(stratygy: IUICustomStrategy<PB>) {
this.strategy = stratygy;
stratygy.update(this.stack);
}
}
class UILastOnlyStrategy implements IUICustomStrategy<any> {
/** 是否启用栈模式 */
readonly stack: boolean;
constructor(stack: boolean = true) {
this.stack = stack;
}
open(
ins: IUIInstance<UIComponent, any>,
stack: IUIInstance<UIComponent, any>[]
): void {
stack.push(ins);
stack.forEach(v => v.hide());
stack.findLast(v => !v.alwaysShow)?.show();
}
close(
_ins: IUIInstance<UIComponent, any>,
stack: IUIInstance<UIComponent, any>[],
index: number
): void {
if (this.stack) {
stack.splice(index);
} else {
stack.splice(index, 1);
}
stack.forEach(v => v.hide());
stack.findLast(v => !v.alwaysShow)?.show();
}
hide(ins: IUIInstance<UIComponent, any>): void {
ins.hide();
}
show(ins: IUIInstance<UIComponent, any>): void {
ins.show();
}
update(stack: IUIInstance<UIComponent, any>[]): void {
stack.forEach(v => v.hide());
stack.findLast(v => !v.alwaysShow)?.show();
}
}
class UIShowAllStrategy implements IUICustomStrategy<any> {
/** 是否启用栈模式 */
readonly stack: boolean;
constructor(stack: boolean = true) {
this.stack = stack;
}
open(
ins: IUIInstance<UIComponent, any>,
stack: IUIInstance<UIComponent, any>[]
): void {
stack.push(ins);
}
close(
_ins: IUIInstance<UIComponent, any>,
stack: IUIInstance<UIComponent, any>[],
index: number
): void {
if (this.stack) {
stack.splice(index);
} else {
stack.splice(index, 1);
}
}
hide(ins: IUIInstance<UIComponent, any>): void {
ins.hide();
}
show(ins: IUIInstance<UIComponent, any>): void {
ins.show();
}
update(stack: IUIInstance<UIComponent, any>[]): void {
stack.forEach(v => v.show());
}
}

View File

@ -1,6 +1,5 @@
export * from './container';
export * from './controller';
export * from './instance';
export * from './shared';
export * from './types';
export * from './ui';

View File

@ -1,40 +1,40 @@
import { Props } from '@motajs/render';
import { IGameUI, IUIInstance, UIComponent, UIProps } from './shared';
import { markRaw, mergeProps } from 'vue';
import { Reactive, reactive, Ref, ref } from 'vue';
import {
IGameUI,
IUIDefaultPropsBase,
IUIInstance,
UIComponent,
UIRawProps
} from './types';
export class UIInstance<C extends UIComponent> implements IUIInstance<C> {
private static counter: number = 0;
/** 自增 UI 实例计数器 */
let counter = 0;
readonly key: number = UIInstance.counter++;
export class UIInstance<
C extends UIComponent,
PB extends IUIDefaultPropsBase<C>
> implements IUIInstance<C, PB> {
readonly key: number = counter++;
readonly ui: IGameUI<C>;
hidden: boolean = false;
readonly hidden: Ref<boolean> = ref(false);
readonly vBind: Reactive<UIRawProps<C, PB>>;
readonly alwaysShow: boolean;
constructor(
ui: IGameUI<C>,
public vBind: UIProps<C>,
public readonly alwaysShow: boolean = false
vBind: UIRawProps<C, PB>,
alwaysShow: boolean = false
) {
this.ui = markRaw(ui);
}
/**
* 设置这个 UI 实例的响应式数据的值
* @param data 要设置的值
* @param merge 是将传入的值与原先的值合并(true),还是将当前值覆盖掉原先的值(false),默认合并
*/
setVBind(data: Partial<Props<C>>, merge: boolean = true) {
if (merge) {
this.vBind = mergeProps(this.vBind, data) as UIProps<C>;
} else {
this.vBind = data as UIProps<C>;
}
this.ui = ui;
this.vBind = reactive(vBind);
this.alwaysShow = alwaysShow;
}
hide(): void {
this.hidden = true;
if (!this.alwaysShow) this.hidden.value = true;
}
show(): void {
this.hidden = false;
this.hidden.value = false;
}
}

View File

@ -1,133 +0,0 @@
import { Props } from '@motajs/render';
import {
DefineComponent,
DefineSetupFnComponent,
Ref,
ShallowRef,
VNode
} from 'vue';
export type UIComponent = DefineSetupFnComponent<any> | DefineComponent;
export interface UIComponentProps<T extends UIComponent = UIComponent> {
controller: IUIMountable;
instance: IUIInstance<T>;
}
export type UIProps<C extends UIComponent = UIComponent> = Omit<
Props<C>,
keyof UIComponentProps<C>
>;
export interface IGameUI<C extends UIComponent = UIComponent> {
/** 这个 UI 的名称 */
readonly name: string;
/** 这个 UI 的组件 */
readonly component: C;
}
export interface IKeepController {
/**
* 安全关闭背景 UI,如果当前没有 UI 已开启,那么直接关闭,否则维持
*/
safelyUnload(): void;
/**
* 不论当前是否有 UI 已开启,都关闭背景
*/
unload(): void;
}
export interface IUIMountable {
/** 当前的 UI 栈 */
readonly stack: IUIInstance<UIComponent>[];
/** 当前的背景 UI */
readonly backIns: ShallowRef<IUIInstance<UIComponent> | null>;
/** 当前是否显示背景 UI */
readonly showBack: Ref<boolean>;
/**
* 隐藏一个 UI
* @param ins 要隐藏的 UI 实例
*/
hide(ins: IUIInstance<UIComponent>): void;
/**
* 显示一个 UI
* @param ins 要显示的 UI 实例
*/
show(ins: IUIInstance<UIComponent>): void;
/**
* 渲染这个 UI,可以直接嵌入渲染树中
*/
render(): VNode;
/**
* 隐藏背景 UI
*/
hideBackground(): void;
/**
* 显示背景 UI
*/
showBackground(): void;
/**
* 打开一个 ui
* @param ui 要打开的 ui
* @param vBind 传递给这个 ui 的响应式数据
* @param alwaysShow 这个 ui 是否保持开启,对于需要叠加显示的 ui 非常有用
*/
open<T extends UIComponent>(
ui: IGameUI<T>,
vBind: UIProps<T>,
alwaysShow?: boolean
): IUIInstance<T>;
/**
* 关闭一个 ui
* @param ui 要关闭的 ui 实例
*/
close(ui: IUIInstance<UIComponent>): void;
/**
* 关闭所有或指定类型的所有 UI
*/
closeAll(ui?: IGameUI<UIComponent>): void;
/**
* 维持背景,直到下次所有 UI 都被关闭
*/
keep(): IKeepController;
}
export interface IUIInstance<C extends UIComponent = UIComponent> {
/** 这个 ui 实例的唯一 key,用于 vue */
readonly key: number;
/** 这个 ui 实例的 ui 信息 */
readonly ui: IGameUI<C>;
/** 传递给这个 ui 实例的响应式数据 */
readonly vBind: UIProps<C>;
/** 当前元素是否被隐藏 */
readonly hidden: boolean;
/** 是否永远保持开启 */
readonly alwaysShow: boolean;
/**
* 设置这个 UI 实例的响应式数据的值
* @param data 要设置的值
* @param merge 是将传入的值与原先的值合并(true),还是将当前值覆盖掉原先的值(false),默认合并
*/
setVBind(data: Partial<Props<C>>, merge?: boolean): void;
/**
* 隐藏这个 ui
*/
hide(): void;
/**
* 显示这个 ui
*/
show(): void;
}

View File

@ -1,4 +1,15 @@
import { ComponentOptions, EmitsOptions, SlotsType } from 'vue';
import { Props } from '@motajs/render';
import {
ComponentOptions,
DefineComponent,
DefineSetupFnComponent,
EmitsOptions,
Reactive,
Ref,
ShallowRef,
SlotsType,
VNode
} from 'vue';
export type SetupComponentOptions<
Props extends Record<string, any>,
@ -10,3 +21,222 @@ export type SetupComponentOptions<
emits?: E | EE[];
slots?: S;
};
export type UIComponent = DefineSetupFnComponent<any> | DefineComponent;
export interface IUIDefaultPropsBase<T extends UIComponent = UIComponent> {
/** 当前打开的 UI 所属的控制器 */
readonly controller: IUIController<this>;
/** 当前 UI 实例对象 */
readonly instance: IUIInstance<T, this>;
}
export interface IGameUI<C extends UIComponent = UIComponent> {
/** 这个 UI 的名称 */
readonly name: string;
/** 这个 UI 的 vue 组件对象 */
readonly component: C;
}
export interface IUICustomStrategy<PB extends IUIDefaultPropsBase<any>> {
/**
* 打开一个新的 UI
* @param ins 要打开的 UI 实例
* @param stack 当前的 UI 栈,还未将 UI 实例加入栈中
*/
open(
ins: IUIInstance<UIComponent, PB>,
stack: IUIInstance<UIComponent, PB>[]
): void;
/**
* 关闭一个 UI
* @param ins 要关闭的 UI 实例
* @param stack 当前的 UI 栈,还未将 UI 实例移除
* @param index 这个 UI 实例在 UI 栈中的索引
*/
close(
ins: IUIInstance<UIComponent, PB>,
stack: IUIInstance<UIComponent, PB>[],
index: number
): void;
/**
* 隐藏一个 UI
* @param ins 要隐藏的 UI 实例,还未进入隐藏状态
* @param stack 当前的 UI 栈
* @param index 这个 UI 实例在 UI 栈中的索引
*/
hide(
ins: IUIInstance<UIComponent, PB>,
stack: IUIInstance<UIComponent, PB>[],
index: number
): void;
/**
* 显示一个 UI
* @param ins 要显示的 UI 实例,还未进入显示状态
* @param stack 当前的 UI 栈
* @param index 这个 UI 实例在 UI 栈中的索引
*/
show(
ins: IUIInstance<UIComponent, PB>,
stack: IUIInstance<UIComponent, PB>[],
index: number
): void;
/**
* 更新所有 UI 的显示状态,一般会在显示模式更改时调用,用于初始化显示模式的显示状态
* @param stack 当前的 UI 栈
*/
update(stack: IUIInstance<UIComponent, PB>[]): void;
}
export interface IKeepController {
/**
* 安全关闭背景 UI,如果当前没有 UI 已开启,那么直接关闭,否则维持
*/
safelyUnload(): void;
/**
* 不论当前是否有 UI 已开启,都关闭背景
*/
unload(): void;
}
/**
* 获取 UI 组件在排除内置基础参数后的剩余参数类型
*/
export type UIRawProps<
C extends UIComponent,
PB extends IUIDefaultPropsBase<any>
> = Omit<Props<C>, keyof PB>;
export interface IUIController<PB extends IUIDefaultPropsBase<any>> {
/** 当前的 UI 栈 */
readonly stack: IUIInstance[];
/** 背景 UI 对象,当显示背景 UI 时,会显示此 UI 对应的实例 */
readonly background: IGameUI | null;
/** 当前的背景 UI 实例的浅层响应式对象 */
readonly backIns: ShallowRef<IUIInstance | null>;
/** 当前 UI 是否已经被激活的响应式对象,即至少有一个 UI 正在显示,或处在 `keep` 环境下 */
readonly active: Ref<boolean>;
/** 当前是否显示背景的响应式对象,用于容器响应式判断是否需要显示背景 */
readonly showBack: Ref<boolean>;
/**
* 设置控制器的背景 UI
* @param background 背景 UI
* @param vBind 当打开背景时,传递给背景的 VBind 参数
* @returns 背景对应的 UI 实例
*/
setBackground<C extends UIComponent>(
background: IGameUI<C> | null,
vBind: UIRawProps<C, PB>
): IUIInstance<C, PB> | null;
/**
* 当前是否显示背景 UI
*/
showingBack(): boolean;
/**
* 隐藏背景 UI
*/
hideBackground(): void;
/**
* 显示背景 UI
*/
showBackground(): void;
/**
* 隐藏一个 UI
* @param ins 要隐藏的 UI 实例
*/
hide(ins: IUIInstance<UIComponent, PB>): void;
/**
* 显示一个 UI
* @param ins 要显示的 UI 实例
*/
show(ins: IUIInstance<UIComponent, PB>): void;
/**
* 打开一个 ui
* @param ui 要打开的 ui
* @param vBind 传递给这个 ui 的响应式数据
* @param alwaysShow 这个 ui 是否保持开启,对于需要叠加显示的 ui 非常有用
*/
open<C extends UIComponent>(
ui: IGameUI<C>,
vBind: UIRawProps<C, PB>,
alwaysShow?: boolean
): IUIInstance<C, PB>;
/**
* 关闭一个 ui
* @param ui 要关闭的 ui 实例
*/
close(ui: IUIInstance<UIComponent, PB>): void;
/**
* 关闭所有或指定类型的所有 UI
*/
closeAll(ui?: IGameUI): void;
/**
* 维持背景 UI,一般用于防闪烁,例如使用道具时可能在关闭道具栏后打开新 UI,这时就需要防闪烁
*/
keep(): IKeepController;
/**
* 渲染这个 UI,可以直接嵌入渲染树中
*/
render(): VNode;
/**
* 设置为仅显示最后一个 UI
* @param stack 是否设置为栈模式,即删除一个 UI 后,其之后打开的 UI 是否也一并删除,默认为栈模式
*/
lastOnly(stack?: boolean): void;
/**
* 设置为显示所有 UI
* @param stack 是否设置为栈模式,即删除一个 UI 后,其之后打开的 UI 是否也一并删除,默认不是栈模式
*/
showAll(stack?: boolean): void;
/**
* 使用自定义的显示模式
* @param strategy 自定义显示模式的配置
*/
showCustom(strategy: IUICustomStrategy<PB>): void;
}
export interface IUIInstance<
C extends UIComponent = UIComponent,
PB extends IUIDefaultPropsBase<C> = IUIDefaultPropsBase<C>
> {
/** 这个 ui 实例的唯一 key,用于 vue */
readonly key: number;
/** 这个 ui 实例的 ui 信息 */
readonly ui: IGameUI<C>;
/** 传递给这个 ui 实例的响应式数据 */
readonly vBind: Reactive<UIRawProps<C, PB>>;
/** 当前元素是否被隐藏 */
readonly hidden: Ref<boolean>;
/** 是否永远保持开启 */
readonly alwaysShow: boolean;
/**
* 隐藏这个 ui
*/
hide(): void;
/**
* 显示这个 ui
*/
show(): void;
}

View File

@ -1,6 +1,9 @@
import { IGameUI, UIComponent } from './shared';
import { IGameUI, UIComponent } from './types';
export class GameUI<C extends UIComponent> implements IGameUI<C> {
// TODO: 这个静态成员是否可以考虑删除?
/** 当前的所有已声明 UI,用于全局性获取 */
static list: Map<string, GameUI<UIComponent>> = new Map();
constructor(