mirror of
https://github.com/motajs/template.git
synced 2026-10-30 21:30:17 +08:00
refactor: UI 换用新的按键系统
This commit is contained in:
parent
2f1e3f5b70
commit
85d4b9f610
@ -1,4 +1,7 @@
|
||||
export * from '../../../../src/client/ui/choices';
|
||||
export * from '../../../../src/client/ui/background';
|
||||
export * from '../../../../src/client/ui/inputBox';
|
||||
export * from '../../../../src/client/ui/waitBox';
|
||||
export * from './floorSelect';
|
||||
export * from './icons';
|
||||
export * from './input';
|
||||
|
||||
@ -12,12 +12,9 @@ import {
|
||||
IRenderTreeRoot
|
||||
} from '@motajs/render';
|
||||
// @ts-expect-error render/use.ts 按 D-54 留在实现层,本文件已移入系统层,按 D-68 暂以标注记录,不反向引用、不解耦
|
||||
import { transitionedColor, useKey } from '../use';
|
||||
import { transitionedColor } from '../use';
|
||||
import { linear } from 'mutate-animate';
|
||||
import { Background, Selection } from './misc';
|
||||
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import { KeyCode } from '@motajs/client-base';
|
||||
import { IGameUIPropsBase } from '../../../client-modules/src/types';
|
||||
import { SetupComponentOptions } from '@motajs/system';
|
||||
|
||||
export interface InputProps extends DefaultProps, Partial<TextContentProps> {
|
||||
/** 输入框的提示内容 */
|
||||
@ -221,14 +218,6 @@ export const Input = defineComponent<InputProps, InputEmits, keyof InputEmits>(
|
||||
borderColor.set(props.border ?? '#ddd');
|
||||
};
|
||||
|
||||
const [key] = useKey();
|
||||
key.realize('confirm', (_, code) => {
|
||||
if (code === KeyCode.Enter) {
|
||||
// 特判回车键
|
||||
ele?.blur();
|
||||
}
|
||||
});
|
||||
|
||||
watch(
|
||||
() => props.value,
|
||||
newValue => {
|
||||
@ -285,313 +274,6 @@ export const Input = defineComponent<InputProps, InputEmits, keyof InputEmits>(
|
||||
inputProps
|
||||
);
|
||||
|
||||
export interface InputBoxProps extends TextContentProps {
|
||||
/** 输入框对话框的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 传递给内部 Input 组件的配置参数,用于自定义输入行为 */
|
||||
input?: InputProps;
|
||||
/** 窗口皮肤图片ID,用于对话框背景绘制 */
|
||||
winskin?: ImageIds;
|
||||
/** 对话框背景颜色,当未设置 winskin 时生效 */
|
||||
color?: CanvasStyle;
|
||||
/** 对话框边框颜色,当未设置 winskin 时生效 */
|
||||
border?: CanvasStyle;
|
||||
/** 对话框内部所有元素的内边距 */
|
||||
pad?: number;
|
||||
/** 内部输入框区域的高度 */
|
||||
inputHeight?: number;
|
||||
/** 对话框顶部的提示文本 */
|
||||
text?: string;
|
||||
/** 确认按钮的显示文本,默认为"确认" */
|
||||
yesText?: string;
|
||||
/** 取消按钮的显示文本,默认为"取消" */
|
||||
noText?: string;
|
||||
/** 确认/取消按钮的字体样式 */
|
||||
selFont?: Font;
|
||||
/** 确认/取消按钮的文本颜色 */
|
||||
selFill?: CanvasStyle;
|
||||
}
|
||||
|
||||
export type InputBoxEmits = {
|
||||
/**
|
||||
* 当确认输入框的内容时触发
|
||||
* @param value 输入框的内容
|
||||
*/
|
||||
confirm: (value: string) => void;
|
||||
|
||||
/**
|
||||
* 当取消时触发
|
||||
* @param value 输入框的内容
|
||||
*/
|
||||
cancel: (value: string) => void;
|
||||
} & InputEmits;
|
||||
|
||||
const inputBoxProps = {
|
||||
props: [
|
||||
'loc',
|
||||
'input',
|
||||
'winskin',
|
||||
'color',
|
||||
'border',
|
||||
'pad',
|
||||
'inputHeight',
|
||||
'text',
|
||||
'yesText',
|
||||
'noText',
|
||||
'selFont',
|
||||
'selFill',
|
||||
'width'
|
||||
],
|
||||
emits: ['confirm', 'cancel', 'change', 'input', 'update:value']
|
||||
} satisfies SetupComponentOptions<
|
||||
InputBoxProps,
|
||||
InputBoxEmits,
|
||||
keyof InputBoxEmits
|
||||
>;
|
||||
|
||||
/**
|
||||
* 输入框组件,与 2.x 的 myconfirm 类似,单次调用参考 {@link getInput}。用法与 `ConfirmBox` 类似。
|
||||
* 参数参考 {@link InputBoxProps},事件参考 {@link InputBoxEmits},用例如下:
|
||||
* ```tsx
|
||||
* const onConfirm = (value: string) => console.log(value);
|
||||
*
|
||||
* <ConfirmBox
|
||||
* text="请输入文本:"
|
||||
* width={240}
|
||||
* // 输入框会自动计算宽度和高度,因此不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 使用 winskin 图片作为背景
|
||||
* winskin="winskin.png"
|
||||
* // 使用颜色作为背景和边框,如果设置了 winskin,那么此参数无效
|
||||
* color="#333"
|
||||
* border="gold"
|
||||
* // 设置选项的字体
|
||||
* selFont={new Font('Verdana', 16)}
|
||||
* // 设置选项的文本颜色
|
||||
* selFill="#d48"
|
||||
* // 完全继承 TextContent 的参数,因此可以填写 font 参数指定文本字体
|
||||
* font={new Font('arial')}
|
||||
* onConfirm={onYes}
|
||||
* // 可以使用 input 参数调整输入组件
|
||||
* input={{
|
||||
* // 例如在输入组件中添加占位符(未输入任何东西时显示此内容)
|
||||
* placeholder: '在这里输入你的文本'
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const InputBox = defineComponent<
|
||||
InputBoxProps,
|
||||
InputBoxEmits,
|
||||
keyof InputBoxEmits
|
||||
>((props, { attrs, emit }) => {
|
||||
const contentHeight = ref(0);
|
||||
const value = ref('');
|
||||
const selected = ref(false);
|
||||
const yesSize = ref<[number, number]>([0, 0]);
|
||||
const noSize = ref<[number, number]>([0, 0]);
|
||||
const height = ref(200);
|
||||
|
||||
const yesText = computed(() => props.yesText ?? '确认');
|
||||
const noText = computed(() => props.noText ?? '取消');
|
||||
const text = computed(() => props.text ?? '请输入内容:');
|
||||
const padding = computed(() => props.pad ?? 8);
|
||||
const inputHeight = computed(() => props.inputHeight ?? 24);
|
||||
const inputLoc = computed<ElementLocator>(() => [
|
||||
padding.value,
|
||||
padding.value * 2 + contentHeight.value,
|
||||
props.width - padding.value * 2,
|
||||
inputHeight.value
|
||||
]);
|
||||
const yesLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - padding.value;
|
||||
return [props.width / 3, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const noLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - padding.value;
|
||||
return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const selectionLoc = computed<ElementLocator>(() => {
|
||||
if (selected.value) {
|
||||
const [x = 0, y = 0] = yesLoc.value;
|
||||
const [width, height] = yesSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
} else {
|
||||
const [x = 0, y = 0] = noLoc.value;
|
||||
const [width, height] = noSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
}
|
||||
});
|
||||
const boxLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, height.value, ax, ay];
|
||||
});
|
||||
|
||||
const updateHeight = (h: number) => {
|
||||
contentHeight.value = h;
|
||||
const [, yh] = yesSize.value;
|
||||
const [, nh] = noSize.value;
|
||||
const buttonHeight = Math.max(yh, nh);
|
||||
height.value = h + inputHeight.value + padding.value * 4 + buttonHeight;
|
||||
};
|
||||
|
||||
const change = (value: string) => {
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
const input = (value: string) => {
|
||||
emit('update:value', value);
|
||||
emit('input', value);
|
||||
};
|
||||
|
||||
const setYes = (width: number, height: number) => {
|
||||
yesSize.value = [width, height];
|
||||
};
|
||||
|
||||
const setNo = (width: number, height: number) => {
|
||||
noSize.value = [width, height];
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm', value.value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel', value.value);
|
||||
};
|
||||
|
||||
return () => (
|
||||
<container loc={boxLoc.value}>
|
||||
<Background
|
||||
loc={[0, 0, props.width, height.value]}
|
||||
winskin={props.winskin}
|
||||
color={props.color ?? '#333'}
|
||||
border={props.border}
|
||||
zIndex={0}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
loc={[padding.value, padding.value]}
|
||||
text={text.value}
|
||||
width={props.width - padding.value * 2}
|
||||
zIndex={5}
|
||||
onUpdateHeight={updateHeight}
|
||||
autoHeight
|
||||
/>
|
||||
<Input
|
||||
{...(props.input ?? {})}
|
||||
loc={inputLoc.value}
|
||||
v-model={value.value}
|
||||
zIndex={10}
|
||||
circle={[4]}
|
||||
onChange={change}
|
||||
onInput={input}
|
||||
/>
|
||||
<Selection
|
||||
loc={selectionLoc.value}
|
||||
winskin={props.winskin}
|
||||
border={props.border}
|
||||
noevent
|
||||
zIndex={10}
|
||||
/>
|
||||
<text
|
||||
loc={yesLoc.value}
|
||||
text={yesText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={confirm}
|
||||
onEnter={() => (selected.value = true)}
|
||||
onResize={setYes}
|
||||
/>
|
||||
<text
|
||||
loc={noLoc.value}
|
||||
text={noText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={cancel}
|
||||
onEnter={() => (selected.value = false)}
|
||||
onResize={setNo}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}, inputBoxProps);
|
||||
|
||||
/**
|
||||
* 弹出一个输入框,然后将结果返回:
|
||||
* ```ts
|
||||
* const value = await getInput(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 提示内容
|
||||
* '请输入文本:',
|
||||
* // 输入框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定字体,此项可选
|
||||
* { font: new Font('Verdana', 20) }
|
||||
* );
|
||||
* // 之后,就可以根据 value 来执行不同的操作了
|
||||
* console.log(value); // 输出用户输入的内容
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export function getInput(
|
||||
controller: IUIController<IGameUIPropsBase>,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: InputBoxProps
|
||||
) {
|
||||
return new Promise<string>(res => {
|
||||
const instance = controller.open(
|
||||
InputBoxUI,
|
||||
{
|
||||
...(props ?? {}),
|
||||
text,
|
||||
loc,
|
||||
width,
|
||||
onConfirm: value => {
|
||||
controller.close(instance);
|
||||
res(value);
|
||||
},
|
||||
onCancel: () => {
|
||||
controller.close(instance);
|
||||
res('');
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `getInput` 类似,不过会将结果转为数字。用法参考 {@link getInput}
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export async function getInputNumber(
|
||||
controller: IUIController<IGameUIPropsBase>,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: InputBoxProps
|
||||
) {
|
||||
const value = await getInput(controller, text, loc, width, props);
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
export async function routedInput() {
|
||||
// todo
|
||||
}
|
||||
@ -599,5 +281,3 @@ export async function routedInput() {
|
||||
export async function routedInputNumber() {
|
||||
// todo
|
||||
}
|
||||
|
||||
export const InputBoxUI = new GameUI('input-box', InputBox);
|
||||
|
||||
@ -4,18 +4,16 @@ import {
|
||||
MotaOffscreenCanvas2D
|
||||
} from '@motajs/render';
|
||||
import { DefaultProps, PathProps } from '@motajs/render-vue';
|
||||
import { computed, defineComponent, ref, SetupContext, watch } from 'vue';
|
||||
import { computed, defineComponent, ref, watch } from 'vue';
|
||||
import { TextContent, TextContentProps } from './textbox';
|
||||
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, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import { SetupComponentOptions } from '@motajs/system';
|
||||
import { clamp } from 'lodash-es';
|
||||
// @ts-expect-error render/renderer 按 D-54 留在实现层,本文件已移入系统层,按 D-59 暂以标注记录,不反向引用、不解耦
|
||||
import { using } from '../renderer';
|
||||
import { cosh, CurveMode } from '@motajs/animate';
|
||||
import { IGameUIPropsBase } from '../../../client-modules/src/types';
|
||||
|
||||
interface ProgressProps extends DefaultProps {
|
||||
/** 进度条的位置 */
|
||||
@ -368,226 +366,3 @@ export const Selection = defineComponent<SelectionProps>(props => {
|
||||
/>
|
||||
);
|
||||
}, selectionProps);
|
||||
|
||||
export interface BackgroundProps extends DefaultProps {
|
||||
loc: ElementLocator;
|
||||
winskin?: ImageIds;
|
||||
color?: CanvasStyle;
|
||||
border?: CanvasStyle;
|
||||
}
|
||||
|
||||
const backgroundProps = {
|
||||
props: ['loc', 'winskin', 'color', 'border']
|
||||
} satisfies SetupComponentOptions<BackgroundProps>;
|
||||
|
||||
/**
|
||||
* 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下:
|
||||
* ```tsx
|
||||
* // 使用 winskin2.png 作为背景
|
||||
* <Background loc={[8, 8, 160, 160]} winskin="winskin2.png" />
|
||||
* // 使用指定填充和边框颜色作为背景
|
||||
* <Background loc={[8, 8, 160, 160]} color="#333" border="gold" />
|
||||
* ```
|
||||
*/
|
||||
export const Background = defineComponent<BackgroundProps>(props => {
|
||||
const isWinskin = computed(() => !!props.winskin);
|
||||
const fixedLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, width = 200, height = 200] = props.loc;
|
||||
return [x + 2, y + 2, width - 4, height - 4];
|
||||
});
|
||||
|
||||
return () =>
|
||||
isWinskin.value ? (
|
||||
<winskin imageName={props.winskin!} loc={props.loc} noanti />
|
||||
) : (
|
||||
<g-rectr
|
||||
loc={fixedLoc.value}
|
||||
fillStyle={props.color}
|
||||
strokeStyle={props.border}
|
||||
fill
|
||||
stroke
|
||||
lineWidth={2}
|
||||
circle={[4]}
|
||||
/>
|
||||
);
|
||||
}, backgroundProps);
|
||||
|
||||
export interface WaitBoxProps<T>
|
||||
extends Partial<BackgroundProps>, Partial<TextContentProps> {
|
||||
loc: ElementLocator;
|
||||
width: number;
|
||||
promise?: Promise<T>;
|
||||
text?: string;
|
||||
pad?: number;
|
||||
}
|
||||
|
||||
export type WaitBoxEmits<T> = {
|
||||
resolve: (data: T) => void;
|
||||
};
|
||||
|
||||
export interface WaitBoxExpose<T> {
|
||||
/**
|
||||
* 手动将此组件兑现,注意调用时如果传入的 Promise 还没有兑现,
|
||||
* 当 Promise 兑现后将不会再次触发 resolve 事件,即 resolve 事件只会被触发一次
|
||||
* @param data 兑现值
|
||||
*/
|
||||
resolve(data: T): void;
|
||||
}
|
||||
|
||||
const waitBoxProps = {
|
||||
props: ['promise', 'loc', 'winskin', 'color', 'border', 'width'],
|
||||
emits: ['resolve']
|
||||
} satisfies SetupComponentOptions<
|
||||
WaitBoxProps<unknown>,
|
||||
WaitBoxEmits<unknown>,
|
||||
keyof WaitBoxEmits<unknown>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 等待框,可以等待某个异步操作 (Promise),操作完毕后触发兑现事件,单次调用参考 {@link waitbox}。
|
||||
* 参数参考 {@link WaitBoxProps},事件参考 {@link WaitBoxEmits},函数接口参考 {@link WaitBoxExpose}。用例如下:
|
||||
* ```tsx
|
||||
* // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻
|
||||
* const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000));
|
||||
*
|
||||
* <WaitBox
|
||||
* // 传入要等待的 Promise
|
||||
* promise={promise}
|
||||
* // 等待框的位置,宽度由 width 参数指定,高度由内部计算得来,不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 等待框的宽度
|
||||
* width={240}
|
||||
* // 完全继承 Background 的参数,因此可以直接指定背景样式
|
||||
* winskin="winskin2.png"
|
||||
* // 完全继承 TextContent 的参数,因此可以直接指定字体
|
||||
* font={new Font('Verdana', 28)}
|
||||
* // 当传入的 Promise 兑现时触发此事件,注意此事件只可能触发一次,触发后便不会再次触发
|
||||
* onResolve={(time) => console.log(time)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const WaitBox = defineComponent<
|
||||
WaitBoxProps<unknown>,
|
||||
WaitBoxEmits<unknown>,
|
||||
keyof WaitBoxEmits<unknown>
|
||||
>(
|
||||
<T,>(
|
||||
props: WaitBoxProps<T>,
|
||||
{ emit, expose, attrs }: SetupContext<WaitBoxEmits<T>>
|
||||
) => {
|
||||
const contentHeight = ref(200);
|
||||
|
||||
const text = computed(() => props.text ?? '请等待 ...');
|
||||
const pad = computed(() => props.pad ?? 24);
|
||||
const containerLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, contentHeight.value, ax, ay];
|
||||
});
|
||||
const backLoc = computed<ElementLocator>(() => {
|
||||
return [1, 1, props.width - 2, contentHeight.value - 2];
|
||||
});
|
||||
const contentLoc = computed<ElementLocator>(() => {
|
||||
return [
|
||||
pad.value,
|
||||
pad.value,
|
||||
props.width - pad.value * 2,
|
||||
contentHeight.value - pad.value * 2
|
||||
];
|
||||
});
|
||||
|
||||
let resolved: boolean = false;
|
||||
|
||||
props.promise?.then(
|
||||
value => {
|
||||
resolve(value);
|
||||
},
|
||||
reason => {
|
||||
logger.warn(63, reason);
|
||||
}
|
||||
);
|
||||
|
||||
const resolve = (data: T) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
emit('resolve', data);
|
||||
};
|
||||
|
||||
const onContentHeight = (height: number) => {
|
||||
contentHeight.value = height + pad.value * 2;
|
||||
};
|
||||
|
||||
expose<WaitBoxExpose<T>>({ resolve });
|
||||
|
||||
return () => (
|
||||
<container loc={containerLoc.value}>
|
||||
<Background
|
||||
loc={backLoc.value}
|
||||
zIndex={0}
|
||||
winskin={props.winskin}
|
||||
color={props.color}
|
||||
border={props.border}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
autoHeight
|
||||
text={text.value}
|
||||
loc={contentLoc.value}
|
||||
width={props.width - pad.value * 2}
|
||||
zIndex={5}
|
||||
onUpdateHeight={onContentHeight}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
},
|
||||
waitBoxProps
|
||||
);
|
||||
|
||||
/**
|
||||
* 打开一个等待框,等待传入的 Promise 兑现后,关闭等待框,并将兑现值返回。
|
||||
* 示例,等待 1000ms:
|
||||
* ```ts
|
||||
* // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻
|
||||
* const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000));
|
||||
* const value = await waitbox(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 确认框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 确认框的宽度
|
||||
* 240,
|
||||
* // 要等待的 Promise
|
||||
* promise,
|
||||
* // 额外的 props,例如填写等待文本,此项可选,参考 WaitBoxProps
|
||||
* { text: '请等待 1000ms' }
|
||||
* );
|
||||
* console.log(value); // 输出时间刻
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param loc 等待框的位置
|
||||
* @param width 等待框的宽度
|
||||
* @param promise 要等待的 Promise
|
||||
* @param props 额外的 props,参考 {@link WaitBoxProps}
|
||||
*/
|
||||
export function waitbox<T>(
|
||||
controller: IUIController<IGameUIPropsBase>,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
promise: Promise<T>,
|
||||
props?: Partial<WaitBoxProps<T>>
|
||||
): Promise<T> {
|
||||
return new Promise<T>(res => {
|
||||
const instance = controller.open(WaitBoxUI, {
|
||||
...(props ?? {}),
|
||||
loc,
|
||||
width,
|
||||
promise,
|
||||
onResolve: data => {
|
||||
controller.close(instance);
|
||||
res(data as T);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const WaitBoxUI = new GameUI('wait-box', WaitBox);
|
||||
export const BackgroundUI = new GameUI('background', Background);
|
||||
|
||||
@ -273,17 +273,17 @@ export function initializeHotkey(state: IClientCore) {
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
}),
|
||||
system.addCommand('@ui/save-up', '选择框向右', {
|
||||
system.addCommand('@ui/save-right', '选择框向右', {
|
||||
defaults: [[KeyCode.RightArrow]],
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
}),
|
||||
system.addCommand('@ui/save-up', '选择框向下', {
|
||||
system.addCommand('@ui/save-down', '选择框向下', {
|
||||
defaults: [[KeyCode.DownArrow]],
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
}),
|
||||
system.addCommand('@ui/save-up', '选择框向左', {
|
||||
system.addCommand('@ui/save-left', '选择框向左', {
|
||||
defaults: [[KeyCode.LeftArrow]],
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
@ -298,12 +298,12 @@ export function initializeHotkey(state: IClientCore) {
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
}),
|
||||
system.addCommand('@ui/save-page-prev', '向前十页', {
|
||||
system.addCommand('@ui/save-page-prev-10', '向前十页', {
|
||||
defaults: [[KeyCode.Ctrl, KeyCode.PageUp]],
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
}),
|
||||
system.addCommand('@ui/save-page-next', '向后十页', {
|
||||
system.addCommand('@ui/save-page-next-10', '向后十页', {
|
||||
defaults: [[KeyCode.Ctrl, KeyCode.PageDown]],
|
||||
mode: HotkeyEmitMode.Continuous,
|
||||
interval: 500
|
||||
@ -343,7 +343,8 @@ export function initializeHotkey(state: IClientCore) {
|
||||
}),
|
||||
system.addCommand('@ui/view-map-book', '怪物手册', {
|
||||
defaults: [[KeyCode.KeyX]],
|
||||
priority: 10
|
||||
priority: 10,
|
||||
block: true
|
||||
}),
|
||||
system.addCommand('@ui/view-map-fly', '传送至', {
|
||||
defaults: [[KeyCode.KeyG]],
|
||||
@ -356,4 +357,14 @@ export function initializeHotkey(state: IClientCore) {
|
||||
]);
|
||||
|
||||
//#endregion
|
||||
|
||||
//#region 输入框
|
||||
|
||||
hotkey.setGroup('@ui/input', '输入框', [
|
||||
system.addCommand('@ui/input-confirm', '输入框确认', {
|
||||
defaults: [[KeyCode.Enter]]
|
||||
})
|
||||
]);
|
||||
|
||||
//#endregion
|
||||
}
|
||||
|
||||
369
src/client/ui/__test__/keyAdaptationGate.test.ts
Normal file
369
src/client/ui/__test__/keyAdaptationGate.test.ts
Normal file
@ -0,0 +1,369 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import inputBoxSource from '../inputBox.tsx?raw';
|
||||
import waitBoxSource from '../waitBox.tsx?raw';
|
||||
import backgroundSource from '../background.tsx?raw';
|
||||
import choicesSource from '../choices.tsx?raw';
|
||||
import saveSource from '../save.tsx?raw';
|
||||
import settingsSource from '../settings.tsx?raw';
|
||||
import titleSource from '../title.tsx?raw';
|
||||
import viewMapSource from '../viewmap.tsx?raw';
|
||||
import componentIndexSource from '../../../../packages-user/client-base/src/components/index.ts?raw';
|
||||
import componentInputSource from '../../../../packages-user/client-base/src/components/input.tsx?raw';
|
||||
import componentMiscSource from '../../../../packages-user/client-base/src/components/misc.tsx?raw';
|
||||
import useHotkeySource from '../../../../packages-user/client-base/src/use/hotkey.ts?raw';
|
||||
import hotkeyDeclSource from '../../misc/hotkey.ts?raw';
|
||||
|
||||
// 取出单条命令声明,从 system.addCommand('<id>' 到下一条命令或分组结束后
|
||||
function getCommandBlock(source: string, id: string): string {
|
||||
const needle = `system.addCommand('${id}'`;
|
||||
const start = source.indexOf(needle);
|
||||
if (start < 0) return '';
|
||||
const next = source.indexOf('system.addCommand(', start + needle.length);
|
||||
const close = source.indexOf(']);', start);
|
||||
if (next < 0 && close < 0) return source.slice(start);
|
||||
if (next < 0) return source.slice(start, close);
|
||||
if (close < 0) return source.slice(start, next);
|
||||
return source.slice(start, Math.min(next, close));
|
||||
}
|
||||
|
||||
// 取出单条按键实现,从 .implement('<id>' 到下一条 .implement( 之前
|
||||
function getImplementBlock(source: string, id: string): string {
|
||||
const needle = `.implement('${id}'`;
|
||||
const start = source.indexOf(needle);
|
||||
if (start < 0) return '';
|
||||
const next = source.indexOf('.implement(', start + needle.length);
|
||||
return next < 0 ? source.slice(start) : source.slice(start, next);
|
||||
}
|
||||
|
||||
// 取出源文件中的全部导入语句,用于只检查导入而不检查正文
|
||||
const IMPORT_KEYWORD = 'im' + 'port ';
|
||||
|
||||
function getImportStatements(source: string): string[] {
|
||||
const lines = source.split(/\r?\n/);
|
||||
const statements: string[] = [];
|
||||
let current = '';
|
||||
for (const line of lines) {
|
||||
if (current === '') {
|
||||
if (!line.startsWith(IMPORT_KEYWORD)) continue;
|
||||
current = line;
|
||||
} else {
|
||||
current += '\n' + line;
|
||||
}
|
||||
if (line.trimEnd().endsWith("';")) {
|
||||
statements.push(current);
|
||||
current = '';
|
||||
}
|
||||
}
|
||||
return statements;
|
||||
}
|
||||
|
||||
// 抹掉块注释、JSX 注释与行注释,避免说明性文字让否定断言假红
|
||||
function stripLineComments(source: string): string {
|
||||
return source
|
||||
.replace(/\{\s*\/\*[\s\S]*?\*\/\s*\}/g, '')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.split(/\r?\n/)
|
||||
.map(line => line.replace(/\/\/.*$/, ''))
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
describe('按键适配源码闸门', () => {
|
||||
// 输入框组件已搬到新文件,命令由只绑 Enter 的 @ui/input-confirm 承担
|
||||
it('moves the input box family into src/client/ui/inputBox.tsx', () => {
|
||||
expect(inputBoxSource).toContain('export interface InputBoxProps');
|
||||
expect(inputBoxSource).toContain('export type InputBoxEmits');
|
||||
expect(inputBoxSource).toContain('export const InputBox');
|
||||
expect(inputBoxSource).toContain('export function getInput(');
|
||||
expect(inputBoxSource).toContain(
|
||||
'export async function getInputNumber('
|
||||
);
|
||||
expect(inputBoxSource).toContain('export const InputBoxUI');
|
||||
expect(inputBoxSource).toContain('IGameUIPropsBase');
|
||||
expect(inputBoxSource).toContain('onConfirm: (value: string)');
|
||||
expect(stripLineComments(inputBoxSource)).not.toContain(
|
||||
"from '@user/client-base'"
|
||||
);
|
||||
expect(stripLineComments(inputBoxSource)).toContain(
|
||||
'packages-user/client-base/src/'
|
||||
);
|
||||
});
|
||||
|
||||
// 等待框组件已搬到新文件,并复用背景组件的参数类型
|
||||
it('moves the wait box family into src/client/ui/waitBox.tsx', () => {
|
||||
expect(waitBoxSource).toContain('export interface WaitBoxProps');
|
||||
expect(waitBoxSource).toContain('export type WaitBoxEmits');
|
||||
expect(waitBoxSource).toContain('export interface WaitBoxExpose');
|
||||
expect(waitBoxSource).toContain('export const WaitBox');
|
||||
expect(waitBoxSource).toContain('export function waitbox');
|
||||
expect(waitBoxSource).toContain('export const WaitBoxUI');
|
||||
expect(waitBoxSource).toContain('BackgroundProps');
|
||||
expect(waitBoxSource).toContain("from './background'");
|
||||
expect(waitBoxSource).toContain('onResolve: (data: T)');
|
||||
expect(stripLineComments(waitBoxSource)).not.toContain(
|
||||
"from '@user/client-base'"
|
||||
);
|
||||
expect(stripLineComments(waitBoxSource)).toContain(
|
||||
'packages-user/client-base/src/'
|
||||
);
|
||||
});
|
||||
|
||||
// 背景组件并入既有文件,同时保留主界面背景,且尺寸常量来自叶子模块
|
||||
it('adds the background component next to the main background', () => {
|
||||
expect(backgroundSource).toContain('export interface BackgroundProps');
|
||||
expect(backgroundSource).toContain('export const Background');
|
||||
expect(backgroundSource).toContain('export const BackgroundUI');
|
||||
expect(backgroundSource).toContain('MainBackground');
|
||||
expect(backgroundSource).toContain('MainBackgroundUI');
|
||||
expect(backgroundSource).toContain('@/client/uiConstant');
|
||||
expect(stripLineComments(backgroundSource)).not.toContain(
|
||||
"from '@/client'"
|
||||
);
|
||||
expect(stripLineComments(backgroundSource)).not.toContain(
|
||||
"from '@user/client-base'"
|
||||
);
|
||||
});
|
||||
|
||||
// 旧居所不再保留输入框一族,也不再使用旧按键 API
|
||||
it('trims the input box and old key handling out of client-base input', () => {
|
||||
const source = stripLineComments(componentInputSource);
|
||||
expect(source).not.toContain('export interface InputBoxProps');
|
||||
expect(source).not.toContain('export const InputBox');
|
||||
expect(source).not.toContain('export function getInput(');
|
||||
expect(source).not.toContain('export const InputBoxUI');
|
||||
expect(source).not.toContain('InputBoxProps');
|
||||
expect(source).not.toContain('useKey');
|
||||
expect(source).not.toContain('KeyCode');
|
||||
expect(source).not.toContain("from './misc'");
|
||||
expect(source).toContain('export const Input');
|
||||
expect(source).toContain('routedInputNumber');
|
||||
});
|
||||
|
||||
// 旧居所不再保留背景与等待框两族,通用组件仍在
|
||||
it('trims the background and wait box families out of client-base misc', () => {
|
||||
const source = stripLineComments(componentMiscSource);
|
||||
expect(source).not.toContain('export interface BackgroundProps');
|
||||
expect(source).not.toContain('export const Background');
|
||||
expect(source).not.toContain('export interface WaitBoxProps');
|
||||
expect(source).not.toContain('export const WaitBox');
|
||||
expect(source).not.toContain('export function waitbox');
|
||||
expect(source).not.toContain('export const WaitBoxUI');
|
||||
expect(source).not.toContain('export const BackgroundUI');
|
||||
expect(source).not.toContain('logger.');
|
||||
expect(source).toContain('export const Selection');
|
||||
expect(source).toContain('export const ScrollText');
|
||||
expect(source).toContain('export const Arrow');
|
||||
expect(source).toContain('export const Progress');
|
||||
});
|
||||
|
||||
// 公共导入面用相对路径再导出搬迁后的三个文件
|
||||
it('re-exports the three moved ui modules from the component barrel', () => {
|
||||
expect(componentIndexSource).toContain(
|
||||
"export * from '../../../../src/client/ui/background';"
|
||||
);
|
||||
expect(componentIndexSource).toContain(
|
||||
"export * from '../../../../src/client/ui/inputBox';"
|
||||
);
|
||||
expect(componentIndexSource).toContain(
|
||||
"export * from '../../../../src/client/ui/waitBox';"
|
||||
);
|
||||
expect(componentIndexSource).toContain(
|
||||
"export * from '../../../../src/client/ui/choices';"
|
||||
);
|
||||
});
|
||||
|
||||
// 选择框改用通用选择命令,并从本地背景模块取得背景组件
|
||||
it('rewires choices to the common selection commands', () => {
|
||||
expect(choicesSource).toContain('@common/select-up');
|
||||
expect(choicesSource).toContain('@common/select-down');
|
||||
expect(choicesSource).toContain('@common/select-left');
|
||||
expect(choicesSource).toContain('@common/select-right');
|
||||
expect(choicesSource).toContain('@common/confirm');
|
||||
const source = stripLineComments(choicesSource);
|
||||
expect(source).not.toContain(".implement('moveUp'");
|
||||
expect(source).not.toContain(".implement('moveDown'");
|
||||
expect(source).not.toContain(".implement('moveLeft'");
|
||||
expect(source).not.toContain(".implement('moveRight'");
|
||||
expect(source).not.toContain(".implement('confirm'");
|
||||
expect(source).toContain("from './background'");
|
||||
expect((source.match(/'state'/g) || []).length).toBeGreaterThanOrEqual(
|
||||
2
|
||||
);
|
||||
expect(source).not.toContain("from '@user/client-base'");
|
||||
expect(source).toContain('packages-user/client-base/src/');
|
||||
});
|
||||
|
||||
// 存档界面十二条命令全部就位,翻页方向逐条落在对应实现块里
|
||||
it('maps all twelve save commands and keeps the page directions', () => {
|
||||
expect(saveSource).toContain(".implement('@common/confirm'");
|
||||
expect(saveSource).toContain(".implement('@common/exit'");
|
||||
expect(saveSource).toContain(".implement('@ui/save'");
|
||||
expect(saveSource).toContain(".implement('@ui/load'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-up'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-down'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-left'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-right'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-page-prev'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-page-next'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-page-prev-10'");
|
||||
expect(saveSource).toContain(".implement('@ui/save-page-next-10'");
|
||||
const source = stripLineComments(saveSource);
|
||||
expect(source).not.toContain(".implement('@save_pageUp'");
|
||||
expect(source).not.toContain(".implement('@save_pageDown'");
|
||||
expect(source).not.toContain(".implement('@save_up'");
|
||||
expect(source).not.toContain(".implement('@save_down'");
|
||||
expect(source).not.toContain(".implement('@save_left'");
|
||||
expect(source).not.toContain(".implement('@save_right'");
|
||||
const directions = [
|
||||
['@ui/save-page-prev', 'movePage(-1)'],
|
||||
['@ui/save-page-next', 'movePage(1)'],
|
||||
['@ui/save-page-prev-10', 'movePage(-10)'],
|
||||
['@ui/save-page-next-10', 'movePage(10)']
|
||||
];
|
||||
for (const [id, call] of directions) {
|
||||
const block = getImplementBlock(source, id);
|
||||
expect(block).toContain(call);
|
||||
for (const [, other] of directions) {
|
||||
if (other !== call) expect(block).not.toContain(other);
|
||||
}
|
||||
}
|
||||
expect(source).toContain("'state'");
|
||||
});
|
||||
|
||||
// 六个界面文件全部只走新按键系统,旧 API 与旧导入销声匿迹
|
||||
it('removes the old key api from all migrated ui files', () => {
|
||||
const sources = [
|
||||
choicesSource,
|
||||
saveSource,
|
||||
settingsSource,
|
||||
titleSource,
|
||||
viewMapSource,
|
||||
inputBoxSource
|
||||
];
|
||||
for (const raw of sources) {
|
||||
const source = stripLineComments(raw);
|
||||
expect(/useKey\(\s*\)/.test(source)).toBe(false);
|
||||
expect(source).not.toContain('.realize(');
|
||||
expect(source).not.toContain('willEmit(');
|
||||
for (const statement of getImportStatements(raw)) {
|
||||
if (statement.includes('@user/client-modules')) {
|
||||
expect(statement).not.toContain('useKey');
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 设置界面的子作用域继承父级实现,且没有新增 legacy 按键用法
|
||||
it('keeps the settings parent scope and leaves game key usage untouched', () => {
|
||||
const source = stripLineComments(settingsSource);
|
||||
expect((source.match(/<Choices(?=[\s>])/g) || []).length).toBe(7);
|
||||
expect((source.match(/freshScope=\{false\}/g) || []).length).toBe(7);
|
||||
expect((source.match(/state=\{props\.state!\}/g) || []).length).toBe(7);
|
||||
expect(
|
||||
(source.match(/props\.state!\.hotkey\.system/g) || []).length
|
||||
).toBe(7);
|
||||
expect(source).not.toMatch(/props\.state\.hotkey\.system/);
|
||||
expect(source).toContain(".implement('@common/exit'");
|
||||
expect(source).not.toContain('scope={scope}');
|
||||
expect(/const\s*\[\s*key\s*,\s*scope\s*\]/.test(source)).toBe(false);
|
||||
expect(source).toContain('getVitualKeyOnce');
|
||||
expect((source.match(/gameKey/g) || []).length).toBe(2);
|
||||
expect((source.match(/gameKey\.emitKey\(/g) || []).length).toBe(1);
|
||||
for (const raw of [
|
||||
saveSource,
|
||||
titleSource,
|
||||
viewMapSource,
|
||||
choicesSource,
|
||||
inputBoxSource
|
||||
]) {
|
||||
expect(
|
||||
(stripLineComments(raw).match(/gameKey/g) || []).length
|
||||
).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
// 标题界面与浏览地图的按键全部改用新命令,地图不再特判怪物手册
|
||||
it('rewires title and view map to the new command ids', () => {
|
||||
expect(titleSource).toContain('@ui/start-up');
|
||||
expect(titleSource).toContain('@ui/start-down');
|
||||
expect(titleSource).toContain('@common/confirm');
|
||||
expect(stripLineComments(titleSource)).not.toContain('@start_up');
|
||||
expect(stripLineComments(titleSource)).not.toContain('@start_down');
|
||||
const source = stripLineComments(viewMapSource);
|
||||
expect(source).toContain(".implement('@ui/view-map-next'");
|
||||
expect(source).toContain(".implement('@ui/view-map-prev'");
|
||||
expect(source).toContain(".implement('@ui/view-map-next-10'");
|
||||
expect(source).toContain(".implement('@ui/view-map-prev-10'");
|
||||
expect(source).toContain(".implement('@ui/view-map-book'");
|
||||
expect(source).toContain(".implement('@ui/view-map-fly'");
|
||||
expect(source).toContain(".implement('@common/confirm'");
|
||||
expect(source).toContain(".implement('@common/exit'");
|
||||
expect(source).not.toContain(".implement('@viewMap_book'");
|
||||
expect(source).not.toContain('willEmit');
|
||||
expect(viewMapSource).toContain('IGameUIPropsBase');
|
||||
expect(viewMapSource).toContain("'state'");
|
||||
});
|
||||
|
||||
// 输入框只认 Enter 的确认键,热键作用域在挂载前进入
|
||||
it('binds the input confirm key and enters the scope before mount', () => {
|
||||
expect(inputBoxSource).toContain(".implement('@ui/input-confirm'");
|
||||
expect(stripLineComments(inputBoxSource)).not.toContain(
|
||||
'@common/confirm'
|
||||
);
|
||||
expect(inputBoxSource).toContain('document.activeElement');
|
||||
expect(inputBoxSource).toContain('blur()');
|
||||
const source = stripLineComments(useHotkeySource);
|
||||
expect(source).toContain('onBeforeMount(');
|
||||
expect(source).toContain('onUnmounted(');
|
||||
expect(source).not.toContain('onMounted(');
|
||||
});
|
||||
|
||||
// 声明层不再出现重复命令 ID
|
||||
it('declares every command id exactly once', () => {
|
||||
const source = stripLineComments(hotkeyDeclSource);
|
||||
const ids = [...source.matchAll(/system\.addCommand\('([^']+)'/g)].map(
|
||||
match => match[1]
|
||||
);
|
||||
expect(new Set(ids).size).toBe(ids.length);
|
||||
for (const id of [
|
||||
'@ui/save-right',
|
||||
'@ui/save-down',
|
||||
'@ui/save-left',
|
||||
'@ui/save-page-prev-10',
|
||||
'@ui/save-page-next-10'
|
||||
]) {
|
||||
expect(ids).toContain(id);
|
||||
}
|
||||
});
|
||||
|
||||
// 怪物手册独占怪物手册键,其它地图指令不阻断
|
||||
it('blocks the exit command only for the view map book key', () => {
|
||||
const source = stripLineComments(hotkeyDeclSource);
|
||||
expect(getCommandBlock(source, '@ui/view-map-book')).toContain(
|
||||
'block: true'
|
||||
);
|
||||
expect(getCommandBlock(source, '@ui/view-map-fly')).not.toContain(
|
||||
'block: true'
|
||||
);
|
||||
});
|
||||
|
||||
// 输入框确认指令只绑 Enter,公共确认键保持三键
|
||||
it('binds input confirm to enter only and keeps common confirm', () => {
|
||||
const source = stripLineComments(hotkeyDeclSource);
|
||||
const block = getCommandBlock(source, '@ui/input-confirm');
|
||||
expect(block).toContain('[[KeyCode.Enter]]');
|
||||
expect(block).not.toContain('KeyCode.Space');
|
||||
expect(block).not.toContain('KeyCode.KeyC');
|
||||
expect(block).not.toContain('mode:');
|
||||
expect(block).not.toContain('priority:');
|
||||
expect(block).not.toContain('interval:');
|
||||
expect(block).not.toContain('block:');
|
||||
expect(
|
||||
(source.match(/system\.addCommand\('@ui\/input-confirm'/g) || [])
|
||||
.length
|
||||
).toBe(1);
|
||||
expect(source).toContain("hotkey.setGroup('@ui/input', '输入框'");
|
||||
const common = getCommandBlock(source, '@common/confirm');
|
||||
expect(common).toContain('KeyCode.Space');
|
||||
expect(common).toContain('KeyCode.KeyC');
|
||||
});
|
||||
});
|
||||
@ -3,8 +3,9 @@ import {
|
||||
SetupComponentOptions,
|
||||
IUIDefaultPropsBase
|
||||
} from '@motajs/system';
|
||||
import { ElementLocator } from '@motajs/render';
|
||||
import { DefaultProps } from '@motajs/render-vue';
|
||||
import { defineComponent } from 'vue';
|
||||
import { computed, defineComponent } from 'vue';
|
||||
import { MAIN_HEIGHT, MAIN_WIDTH } from '@/client';
|
||||
|
||||
export interface MainBackgroundProps
|
||||
@ -26,3 +27,48 @@ export const MainBackground = defineComponent<MainBackgroundProps>(() => {
|
||||
|
||||
/** 主 UI 控制器的背景 UI */
|
||||
export const MainBackgroundUI = new GameUI('main-background', MainBackground);
|
||||
|
||||
export interface BackgroundProps extends DefaultProps {
|
||||
loc: ElementLocator;
|
||||
winskin?: ImageIds;
|
||||
color?: CanvasStyle;
|
||||
border?: CanvasStyle;
|
||||
}
|
||||
|
||||
const backgroundProps = {
|
||||
props: ['loc', 'winskin', 'color', 'border']
|
||||
} satisfies SetupComponentOptions<BackgroundProps>;
|
||||
|
||||
/**
|
||||
* 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下:
|
||||
* ```tsx
|
||||
* // 使用 winskin2.png 作为背景
|
||||
* <Background loc={[8, 8, 160, 160]} winskin="winskin2.png" />
|
||||
* // 使用指定填充和边框颜色作为背景
|
||||
* <Background loc={[8, 8, 160, 160]} color="#333" border="gold" />
|
||||
* ```
|
||||
*/
|
||||
export const Background = defineComponent<BackgroundProps>(props => {
|
||||
const isWinskin = computed(() => !!props.winskin);
|
||||
const fixedLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, width = 200, height = 200] = props.loc;
|
||||
return [x + 2, y + 2, width - 4, height - 4];
|
||||
});
|
||||
|
||||
return () =>
|
||||
isWinskin.value ? (
|
||||
<winskin imageName={props.winskin!} loc={props.loc} noanti />
|
||||
) : (
|
||||
<g-rectr
|
||||
loc={fixedLoc.value}
|
||||
fillStyle={props.color}
|
||||
strokeStyle={props.border}
|
||||
fill
|
||||
stroke
|
||||
lineWidth={2}
|
||||
circle={[4]}
|
||||
/>
|
||||
);
|
||||
}, backgroundProps);
|
||||
|
||||
export const BackgroundUI = new GameUI('background', Background);
|
||||
|
||||
@ -1,16 +1,18 @@
|
||||
import { ElementLocator, Font } from '@motajs/render';
|
||||
import { computed, defineComponent, reactive, ref } from 'vue';
|
||||
import { Background, Selection } from '@user/client-base';
|
||||
import { Background } from './background';
|
||||
import {
|
||||
TextContent,
|
||||
TextContentProps,
|
||||
TextAlign,
|
||||
Selection,
|
||||
Page,
|
||||
PageExpose
|
||||
PageExpose,
|
||||
TextAlign,
|
||||
IGameUIPropsBase,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import { sleep } from 'mutate-animate';
|
||||
import { IGameUIPropsBase, useKey } from '@user/client-base';
|
||||
|
||||
export interface ConfirmBoxProps extends IGameUIPropsBase, TextContentProps {
|
||||
/** 确认框的提示文本内容 */
|
||||
@ -60,7 +62,8 @@ const confirmBoxProps = {
|
||||
'defaultYes',
|
||||
'color',
|
||||
'border',
|
||||
'freshScope'
|
||||
'freshScope',
|
||||
'state'
|
||||
],
|
||||
emits: ['no', 'yes']
|
||||
} satisfies SetupComponentOptions<
|
||||
@ -275,7 +278,8 @@ const choicesProps = {
|
||||
'pad',
|
||||
'interval',
|
||||
'selected',
|
||||
'freshScope'
|
||||
'freshScope',
|
||||
'state'
|
||||
],
|
||||
emits: ['choose']
|
||||
} satisfies SetupComponentOptions<
|
||||
@ -475,7 +479,7 @@ export const Choices = defineComponent<
|
||||
};
|
||||
|
||||
const key = useKey(props.state.hotkey.system, props.freshScope)!;
|
||||
key.implement('moveUp', () => {
|
||||
key.implement('@common/select-up', () => {
|
||||
if (selected.value === 0) {
|
||||
if (pageCom.value?.now() !== 0) {
|
||||
pageCom.value?.movePage(-1);
|
||||
@ -485,7 +489,7 @@ export const Choices = defineComponent<
|
||||
selected.value--;
|
||||
}
|
||||
});
|
||||
key.implement('moveDown', () => {
|
||||
key.implement('@common/select-down', () => {
|
||||
const page = pageCom.value?.now() ?? 0;
|
||||
if (selected.value === choiceCountPerPage.value - 1) {
|
||||
if (page < pages.value - 1) {
|
||||
@ -499,9 +503,9 @@ export const Choices = defineComponent<
|
||||
}
|
||||
}
|
||||
});
|
||||
key.implement('moveLeft', () => pageCom.value?.movePage(-1));
|
||||
key.implement('moveRight', () => pageCom.value?.movePage(1));
|
||||
key.implement('confirm', () => {
|
||||
key.implement('@common/select-left', () => pageCom.value?.movePage(-1));
|
||||
key.implement('@common/select-right', () => pageCom.value?.movePage(1));
|
||||
key.implement('@common/confirm', () => {
|
||||
const page = pageCom.value?.now() ?? 0;
|
||||
const index = page * choiceCountPerPage.value + selected.value;
|
||||
emit('choose', props.choices[index][0]);
|
||||
|
||||
335
src/client/ui/inputBox.tsx
Normal file
335
src/client/ui/inputBox.tsx
Normal file
@ -0,0 +1,335 @@
|
||||
import { ElementLocator, Font } from '@motajs/render';
|
||||
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import { computed, defineComponent, ref } from 'vue';
|
||||
import { Background } from './background';
|
||||
import {
|
||||
Input,
|
||||
InputEmits,
|
||||
InputProps,
|
||||
Selection,
|
||||
TextContent,
|
||||
TextContentProps,
|
||||
IGameUIPropsBase,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
|
||||
export interface InputBoxProps extends IGameUIPropsBase, TextContentProps {
|
||||
/** 输入框对话框的位置 */
|
||||
loc: ElementLocator;
|
||||
/** 传递给内部 Input 组件的配置参数,用于自定义输入行为 */
|
||||
input?: InputProps;
|
||||
/** 窗口皮肤图片ID,用于对话框背景绘制 */
|
||||
winskin?: ImageIds;
|
||||
/** 对话框背景颜色,当未设置 winskin 时生效 */
|
||||
color?: CanvasStyle;
|
||||
/** 对话框边框颜色,当未设置 winskin 时生效 */
|
||||
border?: CanvasStyle;
|
||||
/** 对话框内部所有元素的内边距 */
|
||||
pad?: number;
|
||||
/** 内部输入框区域的高度 */
|
||||
inputHeight?: number;
|
||||
/** 对话框顶部的提示文本 */
|
||||
text?: string;
|
||||
/** 确认按钮的显示文本,默认为"确认" */
|
||||
yesText?: string;
|
||||
/** 取消按钮的显示文本,默认为"取消" */
|
||||
noText?: string;
|
||||
/** 确认/取消按钮的字体样式 */
|
||||
selFont?: Font;
|
||||
/** 确认/取消按钮的文本颜色 */
|
||||
selFill?: CanvasStyle;
|
||||
}
|
||||
|
||||
export type InputBoxEmits = {
|
||||
/**
|
||||
* 当确认输入框的内容时触发
|
||||
* @param value 输入框的内容
|
||||
*/
|
||||
confirm: (value: string) => void;
|
||||
|
||||
/**
|
||||
* 当取消时触发
|
||||
* @param value 输入框的内容
|
||||
*/
|
||||
cancel: (value: string) => void;
|
||||
} & InputEmits;
|
||||
|
||||
const inputBoxProps = {
|
||||
props: [
|
||||
'loc',
|
||||
'input',
|
||||
'winskin',
|
||||
'color',
|
||||
'border',
|
||||
'pad',
|
||||
'inputHeight',
|
||||
'text',
|
||||
'yesText',
|
||||
'noText',
|
||||
'selFont',
|
||||
'selFill',
|
||||
'width',
|
||||
'state'
|
||||
],
|
||||
emits: ['confirm', 'cancel', 'change', 'input', 'update:value']
|
||||
} satisfies SetupComponentOptions<
|
||||
InputBoxProps,
|
||||
InputBoxEmits,
|
||||
keyof InputBoxEmits
|
||||
>;
|
||||
|
||||
/**
|
||||
* 输入框组件,与 2.x 的 myconfirm 类似,单次调用参考 {@link getInput}。用法与 `ConfirmBox` 类似。
|
||||
* 参数参考 {@link InputBoxProps},事件参考 {@link InputBoxEmits},用例如下:
|
||||
* ```tsx
|
||||
* const onConfirm = (value: string) => console.log(value);
|
||||
*
|
||||
* <ConfirmBox
|
||||
* text="请输入文本:"
|
||||
* width={240}
|
||||
* // 输入框会自动计算宽度和高度,因此不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 使用 winskin 图片作为背景
|
||||
* winskin="winskin.png"
|
||||
* // 使用颜色作为背景和边框,如果设置了 winskin,那么此参数无效
|
||||
* color="#333"
|
||||
* border="gold"
|
||||
* // 设置选项的字体
|
||||
* selFont={new Font('Verdana', 16)}
|
||||
* // 设置选项的文本颜色
|
||||
* selFill="#d48"
|
||||
* // 完全继承 TextContent 的参数,因此可以填写 font 参数指定文本字体
|
||||
* font={new Font('arial')}
|
||||
* onConfirm={onYes}
|
||||
* // 可以使用 input 参数调整输入组件
|
||||
* input={{
|
||||
* // 例如在输入组件中添加占位符(未输入任何东西时显示此内容)
|
||||
* placeholder: '在这里输入你的文本'
|
||||
* }}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const InputBox = defineComponent<
|
||||
InputBoxProps,
|
||||
InputBoxEmits,
|
||||
keyof InputBoxEmits
|
||||
>((props, { attrs, emit }) => {
|
||||
const contentHeight = ref(0);
|
||||
const value = ref('');
|
||||
const selected = ref(false);
|
||||
const yesSize = ref<[number, number]>([0, 0]);
|
||||
const noSize = ref<[number, number]>([0, 0]);
|
||||
const height = ref(200);
|
||||
|
||||
const yesText = computed(() => props.yesText ?? '确认');
|
||||
const noText = computed(() => props.noText ?? '取消');
|
||||
const text = computed(() => props.text ?? '请输入内容:');
|
||||
const padding = computed(() => props.pad ?? 8);
|
||||
const inputHeight = computed(() => props.inputHeight ?? 24);
|
||||
const inputLoc = computed<ElementLocator>(() => [
|
||||
padding.value,
|
||||
padding.value * 2 + contentHeight.value,
|
||||
props.width - padding.value * 2,
|
||||
inputHeight.value
|
||||
]);
|
||||
const yesLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - padding.value;
|
||||
return [props.width / 3, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const noLoc = computed<ElementLocator>(() => {
|
||||
const y = height.value - padding.value;
|
||||
return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1];
|
||||
});
|
||||
const selectionLoc = computed<ElementLocator>(() => {
|
||||
if (selected.value) {
|
||||
const [x = 0, y = 0] = yesLoc.value;
|
||||
const [width, height] = yesSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
} else {
|
||||
const [x = 0, y = 0] = noLoc.value;
|
||||
const [width, height] = noSize.value;
|
||||
return [x, y + 4, width + 8, height + 8, 0.5, 1];
|
||||
}
|
||||
});
|
||||
const boxLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, height.value, ax, ay];
|
||||
});
|
||||
|
||||
const updateHeight = (h: number) => {
|
||||
contentHeight.value = h;
|
||||
const [, yh] = yesSize.value;
|
||||
const [, nh] = noSize.value;
|
||||
const buttonHeight = Math.max(yh, nh);
|
||||
height.value = h + inputHeight.value + padding.value * 4 + buttonHeight;
|
||||
};
|
||||
|
||||
const change = (value: string) => {
|
||||
emit('change', value);
|
||||
};
|
||||
|
||||
const input = (value: string) => {
|
||||
emit('update:value', value);
|
||||
emit('input', value);
|
||||
};
|
||||
|
||||
const setYes = (width: number, height: number) => {
|
||||
yesSize.value = [width, height];
|
||||
};
|
||||
|
||||
const setNo = (width: number, height: number) => {
|
||||
noSize.value = [width, height];
|
||||
};
|
||||
|
||||
const confirm = () => {
|
||||
emit('confirm', value.value);
|
||||
};
|
||||
|
||||
const cancel = () => {
|
||||
emit('cancel', value.value);
|
||||
};
|
||||
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@ui/input-confirm', () => {
|
||||
const active = document.activeElement;
|
||||
if (
|
||||
active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement
|
||||
) {
|
||||
active.blur();
|
||||
}
|
||||
});
|
||||
|
||||
return () => (
|
||||
<container loc={boxLoc.value}>
|
||||
<Background
|
||||
loc={[0, 0, props.width, height.value]}
|
||||
winskin={props.winskin}
|
||||
color={props.color ?? '#333'}
|
||||
border={props.border}
|
||||
zIndex={0}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
loc={[padding.value, padding.value]}
|
||||
text={text.value}
|
||||
width={props.width - padding.value * 2}
|
||||
zIndex={5}
|
||||
onUpdateHeight={updateHeight}
|
||||
autoHeight
|
||||
/>
|
||||
<Input
|
||||
{...(props.input ?? {})}
|
||||
loc={inputLoc.value}
|
||||
v-model={value.value}
|
||||
zIndex={10}
|
||||
circle={[4]}
|
||||
onChange={change}
|
||||
onInput={input}
|
||||
/>
|
||||
<Selection
|
||||
loc={selectionLoc.value}
|
||||
winskin={props.winskin}
|
||||
border={props.border}
|
||||
noevent
|
||||
zIndex={10}
|
||||
/>
|
||||
<text
|
||||
loc={yesLoc.value}
|
||||
text={yesText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={confirm}
|
||||
onEnter={() => (selected.value = true)}
|
||||
onResize={setYes}
|
||||
/>
|
||||
<text
|
||||
loc={noLoc.value}
|
||||
text={noText.value}
|
||||
fillStyle={props.selFill}
|
||||
font={props.selFont}
|
||||
cursor="pointer"
|
||||
zIndex={15}
|
||||
onClick={cancel}
|
||||
onEnter={() => (selected.value = false)}
|
||||
onResize={setNo}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
}, inputBoxProps);
|
||||
|
||||
/**
|
||||
* 弹出一个输入框,然后将结果返回:
|
||||
* ```ts
|
||||
* const value = await getInput(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 提示内容
|
||||
* '请输入文本:',
|
||||
* // 输入框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 宽度设为 240
|
||||
* 240,
|
||||
* // 可以给选择框传入其他的 props,例如指定字体,此项可选
|
||||
* { font: new Font('Verdana', 20) }
|
||||
* );
|
||||
* // 之后,就可以根据 value 来执行不同的操作了
|
||||
* console.log(value); // 输出用户输入的内容
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export function getInput(
|
||||
controller: IUIController<IGameUIPropsBase>,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: InputBoxProps
|
||||
) {
|
||||
return new Promise<string>(res => {
|
||||
const instance = controller.open(
|
||||
InputBoxUI,
|
||||
{
|
||||
...(props ?? {}),
|
||||
text,
|
||||
loc,
|
||||
width,
|
||||
onConfirm: (value: string) => {
|
||||
controller.close(instance);
|
||||
res(value);
|
||||
},
|
||||
onCancel: () => {
|
||||
controller.close(instance);
|
||||
res('');
|
||||
}
|
||||
},
|
||||
true
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 与 `getInput` 类似,不过会将结果转为数字。用法参考 {@link getInput}
|
||||
* @param controller UI 控制器
|
||||
* @param text 确认文本内容
|
||||
* @param loc 确认框的位置
|
||||
* @param width 确认框的宽度
|
||||
* @param props 额外的 props,参考 {@link ConfirmBoxProps}
|
||||
*/
|
||||
export async function getInputNumber(
|
||||
controller: IUIController<IGameUIPropsBase>,
|
||||
text: string,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
props?: InputBoxProps
|
||||
) {
|
||||
const value = await getInput(controller, text, loc, width, props);
|
||||
return parseFloat(value);
|
||||
}
|
||||
|
||||
export const InputBoxUI = new GameUI('input-box', InputBox);
|
||||
@ -15,11 +15,12 @@ import {
|
||||
Thumbnail,
|
||||
TipStore,
|
||||
IGameUIPropsBase,
|
||||
MAP_BLOCK_WIDTH
|
||||
MAP_BLOCK_WIDTH,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { HALF_HEIGHT, HALF_WIDTH, MAP_WIDTH, POP_BOX_WIDTH } from '@/client';
|
||||
import { adjustGrid, IGridLayoutData } from '@user/client-base';
|
||||
import { getSave, IUIPropsBase, SaveData, useKey } from '@user/client-modules';
|
||||
import { getSave, IUIPropsBase, SaveData } from '@user/client-modules';
|
||||
|
||||
/** 存档缩略图尺寸 */
|
||||
export const SAVE_ITEM_SIZE = MAP_BLOCK_WIDTH * 10;
|
||||
@ -65,7 +66,7 @@ export type SaveEmits = {
|
||||
};
|
||||
|
||||
const saveProps = {
|
||||
props: ['loc', 'controller', 'instance', 'mode'],
|
||||
props: ['loc', 'controller', 'instance', 'mode', 'state'],
|
||||
emits: ['delete', 'emit', 'exit']
|
||||
} satisfies SetupComponentOptions<SaveProps, SaveEmits, keyof SaveEmits>;
|
||||
|
||||
@ -299,8 +300,8 @@ export const Save = defineComponent<SaveProps, SaveEmits, keyof SaveEmits>(
|
||||
|
||||
// #region 按键实现
|
||||
|
||||
const [key] = useKey();
|
||||
key.realize('confirm', () => {
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@common/confirm', () => {
|
||||
if (selected.value === 0) {
|
||||
emitSave(-1);
|
||||
} else {
|
||||
@ -308,92 +309,74 @@ export const Save = defineComponent<SaveProps, SaveEmits, keyof SaveEmits>(
|
||||
(grid.value.count - 1) * now.value + selected.value - 1
|
||||
);
|
||||
}
|
||||
})
|
||||
.realize('exit', exit)
|
||||
.realize('save', () => {
|
||||
if (props.mode === SaveMode.Save) exit();
|
||||
})
|
||||
.realize('load', () => {
|
||||
if (props.mode === SaveMode.Load) exit();
|
||||
})
|
||||
.realize(
|
||||
'@save_pageUp',
|
||||
() => {
|
||||
pageRef.value?.movePage(1);
|
||||
},
|
||||
{ type: 'down-repeat' }
|
||||
)
|
||||
.realize(
|
||||
'@save_pageDown',
|
||||
() => {
|
||||
});
|
||||
key.implement('@common/exit', exit);
|
||||
key.implement('@ui/save', () => {
|
||||
if (props.mode === SaveMode.Save) exit();
|
||||
});
|
||||
key.implement('@ui/load', () => {
|
||||
if (props.mode === SaveMode.Load) exit();
|
||||
});
|
||||
key.implement('@ui/save-page-prev', () => {
|
||||
pageRef.value?.movePage(-1);
|
||||
});
|
||||
key.implement('@ui/save-page-next', () => {
|
||||
pageRef.value?.movePage(1);
|
||||
});
|
||||
key.implement('@ui/save-page-prev-10', () => {
|
||||
pageRef.value?.movePage(-10);
|
||||
});
|
||||
key.implement('@ui/save-page-next-10', () => {
|
||||
pageRef.value?.movePage(10);
|
||||
});
|
||||
key.implement('@ui/save-up', () => {
|
||||
if (!pageRef.value) return;
|
||||
const cols = grid.value.cols;
|
||||
const count = grid.value.count;
|
||||
if (selected.value >= cols) {
|
||||
selected.value -= cols;
|
||||
} else {
|
||||
if (now.value === 0) {
|
||||
selected.value = 0;
|
||||
} else {
|
||||
const selectedCol = selected.value % cols;
|
||||
selected.value = count - (cols - selectedCol);
|
||||
pageRef.value?.movePage(-1);
|
||||
},
|
||||
{ type: 'down-repeat' }
|
||||
)
|
||||
.realize(
|
||||
'@save_up',
|
||||
() => {
|
||||
if (!pageRef.value) return;
|
||||
const cols = grid.value.cols;
|
||||
const count = grid.value.count;
|
||||
if (selected.value >= cols) {
|
||||
selected.value -= cols;
|
||||
} else {
|
||||
if (now.value === 0) {
|
||||
selected.value = 0;
|
||||
} else {
|
||||
const selectedCol = selected.value % cols;
|
||||
selected.value = count - (cols - selectedCol);
|
||||
pageRef.value?.movePage(-1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'down-repeat' }
|
||||
)
|
||||
.realize(
|
||||
'@save_down',
|
||||
() => {
|
||||
const cols = grid.value.cols;
|
||||
const count = grid.value.count;
|
||||
if (selected.value < count - cols) {
|
||||
selected.value += cols;
|
||||
} else {
|
||||
const selectedCol = selected.value % cols;
|
||||
selected.value = selectedCol;
|
||||
pageRef.value?.movePage(1);
|
||||
}
|
||||
},
|
||||
{ type: 'down-repeat' }
|
||||
)
|
||||
.realize(
|
||||
'@save_left',
|
||||
() => {
|
||||
if (!pageRef.value) return;
|
||||
const count = grid.value.count;
|
||||
if (selected.value > 0) {
|
||||
selected.value--;
|
||||
} else {
|
||||
if (now.value > 0) {
|
||||
selected.value = count;
|
||||
pageRef.value?.movePage(-1);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ type: 'down-repeat' }
|
||||
)
|
||||
.realize(
|
||||
'@save_right',
|
||||
() => {
|
||||
const count = grid.value.count;
|
||||
if (selected.value < count) {
|
||||
selected.value++;
|
||||
} else {
|
||||
selected.value = 0;
|
||||
pageRef.value?.movePage(1);
|
||||
}
|
||||
},
|
||||
{ type: 'down-repeat' }
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
key.implement('@ui/save-down', () => {
|
||||
const cols = grid.value.cols;
|
||||
const count = grid.value.count;
|
||||
if (selected.value < count - cols) {
|
||||
selected.value += cols;
|
||||
} else {
|
||||
const selectedCol = selected.value % cols;
|
||||
selected.value = selectedCol;
|
||||
pageRef.value?.movePage(1);
|
||||
}
|
||||
});
|
||||
key.implement('@ui/save-left', () => {
|
||||
if (!pageRef.value) return;
|
||||
const count = grid.value.count;
|
||||
if (selected.value > 0) {
|
||||
selected.value--;
|
||||
} else {
|
||||
if (now.value > 0) {
|
||||
selected.value = count;
|
||||
pageRef.value?.movePage(-1);
|
||||
}
|
||||
}
|
||||
});
|
||||
key.implement('@ui/save-right', () => {
|
||||
const count = grid.value.count;
|
||||
if (selected.value < count) {
|
||||
selected.value++;
|
||||
} else {
|
||||
selected.value = 0;
|
||||
pageRef.value?.movePage(1);
|
||||
}
|
||||
});
|
||||
|
||||
//#region 事件监听
|
||||
|
||||
|
||||
@ -8,7 +8,8 @@ import {
|
||||
ChoicesProps,
|
||||
getConfirm,
|
||||
waitbox,
|
||||
IGameUIPropsBase
|
||||
IGameUIPropsBase,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { gameKey, generateKeyboardEvent } from '@motajs/system';
|
||||
import { getInput } from '@user/client-base';
|
||||
@ -17,9 +18,12 @@ import { saveWithExist } from './save';
|
||||
import { compressToBase64 } from 'lz-string';
|
||||
import { ViewMapUI } from './viewmap';
|
||||
import { CENTER_LOC, FULL_LOC, MAIN_HEIGHT, POP_BOX_WIDTH } from '@/client';
|
||||
import { IUIPropsBase, useKey } from '@user/client-modules';
|
||||
|
||||
export interface MainSettingsProps extends Partial<ChoicesProps>, IUIPropsBase {
|
||||
// 这个类型有点离谱了,换成新的设置系统之后要改
|
||||
export interface MainSettingsProps
|
||||
extends
|
||||
Partial<Omit<ChoicesProps, keyof IGameUIPropsBase>>,
|
||||
IGameUIPropsBase {
|
||||
loc: ElementLocator;
|
||||
}
|
||||
|
||||
@ -54,8 +58,8 @@ export const MainSettings = defineComponent<MainSettingsProps>(props => {
|
||||
];
|
||||
|
||||
const controller: IUIController<IGameUIPropsBase> = props.controller;
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -120,7 +124,8 @@ export const MainSettings = defineComponent<MainSettingsProps>(props => {
|
||||
onChoose={choose}
|
||||
maxHeight={MAIN_HEIGHT - 64}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
@ -146,8 +151,8 @@ export const ReplaySettings = defineComponent<MainSettingsProps>(props => {
|
||||
[ReplayChoice.Back, '返回游戏']
|
||||
];
|
||||
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -241,7 +246,8 @@ export const ReplaySettings = defineComponent<MainSettingsProps>(props => {
|
||||
width={POP_BOX_WIDTH}
|
||||
onChoose={choose}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
@ -265,8 +271,8 @@ export const GameInfo = defineComponent<MainSettingsProps>(props => {
|
||||
[GameInfoChoice.Back, '返回主菜单']
|
||||
];
|
||||
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -332,7 +338,8 @@ export const GameInfo = defineComponent<MainSettingsProps>(props => {
|
||||
width={POP_BOX_WIDTH}
|
||||
onChoose={choose}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
@ -361,8 +368,8 @@ export const SyncSave = defineComponent<MainSettingsProps>(props => {
|
||||
];
|
||||
|
||||
const controller: IUIController<IGameUIPropsBase> = props.controller;
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -406,7 +413,8 @@ export const SyncSave = defineComponent<MainSettingsProps>(props => {
|
||||
choices={choices}
|
||||
onChoose={choose}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
@ -418,8 +426,8 @@ export const SyncSaveSelect = defineComponent<MainSettingsProps>(props => {
|
||||
[SyncSaveChoice.Back, '返回上一级']
|
||||
];
|
||||
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -463,7 +471,8 @@ export const SyncSaveSelect = defineComponent<MainSettingsProps>(props => {
|
||||
choices={choices}
|
||||
onChoose={choose}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
@ -475,8 +484,8 @@ export const DownloadSaveSelect = defineComponent<MainSettingsProps>(props => {
|
||||
[SyncSaveChoice.Back, '返回上一级']
|
||||
];
|
||||
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -536,7 +545,8 @@ export const DownloadSaveSelect = defineComponent<MainSettingsProps>(props => {
|
||||
choices={choices}
|
||||
onChoose={choose}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
@ -548,8 +558,8 @@ export const ClearSaveSelect = defineComponent<MainSettingsProps>(props => {
|
||||
[SyncSaveChoice.Back, '返回上一级']
|
||||
];
|
||||
|
||||
const [key, scope] = useKey();
|
||||
key.realize('exit', () => props.controller.close(props.instance));
|
||||
const key = useKey(props.state!.hotkey.system)!;
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
const choose = async (key: ChoiceKey) => {
|
||||
switch (key) {
|
||||
@ -649,7 +659,8 @@ export const ClearSaveSelect = defineComponent<MainSettingsProps>(props => {
|
||||
choices={choices}
|
||||
onChoose={choose}
|
||||
interval={8}
|
||||
scope={scope}
|
||||
freshScope={false}
|
||||
state={props.state!}
|
||||
/>
|
||||
);
|
||||
}, mainSettingsProps);
|
||||
|
||||
@ -7,7 +7,8 @@ import {
|
||||
ExitFullscreen,
|
||||
Fullscreen,
|
||||
SoundVolume,
|
||||
triggerFullscreen
|
||||
triggerFullscreen,
|
||||
useKey
|
||||
} from '@user/client-base';
|
||||
import { saveLoad } from './save';
|
||||
import { MainSceneUI } from './main';
|
||||
@ -18,8 +19,7 @@ import {
|
||||
ITransitionedController,
|
||||
IUIPropsBase,
|
||||
transitioned,
|
||||
transitionedColor,
|
||||
useKey
|
||||
transitionedColor
|
||||
} from '@user/client-modules';
|
||||
|
||||
/** 标题图 */
|
||||
@ -268,47 +268,39 @@ export const GameTitle = defineComponent<GameTitleProps>(props => {
|
||||
|
||||
const selected = ref(0);
|
||||
|
||||
const [key] = useKey();
|
||||
key.realize(
|
||||
'@start_up',
|
||||
() => {
|
||||
selected.value--;
|
||||
if (selected.value < 0) {
|
||||
selected.value = 0;
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@ui/start-up', () => {
|
||||
selected.value--;
|
||||
if (selected.value < 0) {
|
||||
selected.value = 0;
|
||||
}
|
||||
if (selectHard.value) {
|
||||
enterHard(selected.value);
|
||||
} else {
|
||||
enterMain(selected.value);
|
||||
}
|
||||
});
|
||||
key.implement('@ui/start-down', () => {
|
||||
selected.value++;
|
||||
if (selectHard.value) {
|
||||
if (selected.value > hard.length - 1) {
|
||||
selected.value = hard.length - 1;
|
||||
}
|
||||
if (selectHard.value) {
|
||||
enterHard(selected.value);
|
||||
} else {
|
||||
enterMain(selected.value);
|
||||
enterHard(selected.value);
|
||||
} else {
|
||||
if (selected.value > buttons.length - 1) {
|
||||
selected.value = buttons.length - 1;
|
||||
}
|
||||
},
|
||||
{ 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);
|
||||
}
|
||||
});
|
||||
enterMain(selected.value);
|
||||
}
|
||||
});
|
||||
key.implement('@common/confirm', () => {
|
||||
if (selectHard.value) {
|
||||
clickButton(hard[selected.value].code, selected.value);
|
||||
} else {
|
||||
clickButton(buttons[selected.value].code, selected.value);
|
||||
}
|
||||
});
|
||||
|
||||
//#region 鼠标操作
|
||||
|
||||
|
||||
@ -7,12 +7,7 @@ import {
|
||||
Font
|
||||
} from '@motajs/render';
|
||||
import { BaseProps } from '@motajs/render-vue';
|
||||
import {
|
||||
GameUI,
|
||||
IUIController,
|
||||
SetupComponentOptions,
|
||||
IUIDefaultPropsBase
|
||||
} from '@motajs/system';
|
||||
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import {
|
||||
computed,
|
||||
defineComponent,
|
||||
@ -26,7 +21,7 @@ import {
|
||||
import { FloorSelector } from '@user/client-base';
|
||||
import { clamp, mean } from 'lodash-es';
|
||||
import { StatisticsDataOneFloor } from './statistics';
|
||||
import { Tip, TipExpose, IGameUIPropsBase } from '@user/client-base';
|
||||
import { Tip, TipExpose, IGameUIPropsBase, useKey } from '@user/client-base';
|
||||
import {
|
||||
ENABLE_RIGHT_STATUS_BAR,
|
||||
FULL_LOC,
|
||||
@ -39,15 +34,14 @@ import {
|
||||
STATUS_BAR_HEIGHT,
|
||||
STATUS_BAR_WIDTH
|
||||
} from '@/client';
|
||||
import { useKey } from '@user/client-modules';
|
||||
|
||||
export interface ViewMapProps extends IUIDefaultPropsBase, BaseProps {
|
||||
export interface ViewMapProps extends IGameUIPropsBase, BaseProps {
|
||||
loc: ElementLocator;
|
||||
floorId?: FloorIds;
|
||||
}
|
||||
|
||||
const viewMapProps = {
|
||||
props: ['loc', 'floorId', 'controller', 'instance']
|
||||
props: ['loc', 'floorId', 'controller', 'instance', 'state']
|
||||
} satisfies SetupComponentOptions<ViewMapProps>;
|
||||
|
||||
export const ViewMap = defineComponent<ViewMapProps>(props => {
|
||||
@ -90,23 +84,16 @@ export const ViewMap = defineComponent<ViewMapProps>(props => {
|
||||
|
||||
//#region 按键实现
|
||||
|
||||
const [key] = useKey();
|
||||
key.realize('@viewMap_up', () => changeFloor(1), { type: 'down-repeat' })
|
||||
.realize('@viewMap_down', () => changeFloor(-1), {
|
||||
type: 'down-repeat'
|
||||
})
|
||||
.realize('@viewMap_up_ten', () => changeFloor(10))
|
||||
.realize('@viewMap_down_ten', () => changeFloor(-10))
|
||||
.realize('@viewMap_book', () => openBook())
|
||||
.realize('@viewMap_fly', () => fly())
|
||||
// .realize('@viewMap_reset', () => resetCamera())
|
||||
.realize('confirm', () => close())
|
||||
.realize('exit', (_, code, assist) => {
|
||||
// 如果按键不能触发怪物手册,则关闭界面,因为怪物手册和退出默认使用同一个按键,需要特判
|
||||
if (!key.willEmit(code, assist, 'book')) {
|
||||
props.controller.close(props.instance);
|
||||
}
|
||||
});
|
||||
const key = useKey(props.state.hotkey.system)!;
|
||||
key.implement('@ui/view-map-next', () => changeFloor(1));
|
||||
key.implement('@ui/view-map-prev', () => changeFloor(-1));
|
||||
key.implement('@ui/view-map-next-10', () => changeFloor(10));
|
||||
key.implement('@ui/view-map-prev-10', () => changeFloor(-10));
|
||||
key.implement('@ui/view-map-book', () => openBook());
|
||||
key.implement('@ui/view-map-fly', () => fly());
|
||||
// .realize('@viewMap_reset', () => resetCamera())
|
||||
key.implement('@common/confirm', () => close());
|
||||
key.implement('@common/exit', () => props.controller.close(props.instance));
|
||||
|
||||
//#region 功能函数
|
||||
|
||||
|
||||
189
src/client/ui/waitBox.tsx
Normal file
189
src/client/ui/waitBox.tsx
Normal file
@ -0,0 +1,189 @@
|
||||
import { logger } from '@motajs/common';
|
||||
import { ElementLocator } from '@motajs/render';
|
||||
import { GameUI, IUIController, SetupComponentOptions } from '@motajs/system';
|
||||
import { computed, defineComponent, ref, SetupContext } from 'vue';
|
||||
import { Background, BackgroundProps } from './background';
|
||||
import {
|
||||
TextContent,
|
||||
TextContentProps,
|
||||
IGameUIPropsBase
|
||||
} from '@user/client-base';
|
||||
|
||||
export interface WaitBoxProps<T>
|
||||
extends Partial<BackgroundProps>, Partial<TextContentProps> {
|
||||
loc: ElementLocator;
|
||||
width: number;
|
||||
promise?: Promise<T>;
|
||||
text?: string;
|
||||
pad?: number;
|
||||
}
|
||||
|
||||
export type WaitBoxEmits<T> = {
|
||||
resolve: (data: T) => void;
|
||||
};
|
||||
|
||||
export interface WaitBoxExpose<T> {
|
||||
/**
|
||||
* 手动将此组件兑现,注意调用时如果传入的 Promise 还没有兑现,
|
||||
* 当 Promise 兑现后将不会再次触发 resolve 事件,即 resolve 事件只会被触发一次
|
||||
* @param data 兑现值
|
||||
*/
|
||||
resolve(data: T): void;
|
||||
}
|
||||
|
||||
const waitBoxProps = {
|
||||
props: ['promise', 'loc', 'winskin', 'color', 'border', 'width'],
|
||||
emits: ['resolve']
|
||||
} satisfies SetupComponentOptions<
|
||||
WaitBoxProps<unknown>,
|
||||
WaitBoxEmits<unknown>,
|
||||
keyof WaitBoxEmits<unknown>
|
||||
>;
|
||||
|
||||
/**
|
||||
* 等待框,可以等待某个异步操作 (Promise),操作完毕后触发兑现事件,单次调用参考 {@link waitbox}。
|
||||
* 参数参考 {@link WaitBoxProps},事件参考 {@link WaitBoxEmits},函数接口参考 {@link WaitBoxExpose}。用例如下:
|
||||
* ```tsx
|
||||
* // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻
|
||||
* const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000));
|
||||
*
|
||||
* <WaitBox
|
||||
* // 传入要等待的 Promise
|
||||
* promise={promise}
|
||||
* // 等待框的位置,宽度由 width 参数指定,高度由内部计算得来,不需要手动指定,即使手动指定也无效
|
||||
* loc={[240, 240, void 0, void 0, 0.5, 0.5]}
|
||||
* // 等待框的宽度
|
||||
* width={240}
|
||||
* // 完全继承 Background 的参数,因此可以直接指定背景样式
|
||||
* winskin="winskin2.png"
|
||||
* // 完全继承 TextContent 的参数,因此可以直接指定字体
|
||||
* font={new Font('Verdana', 28)}
|
||||
* // 当传入的 Promise 兑现时触发此事件,注意此事件只可能触发一次,触发后便不会再次触发
|
||||
* onResolve={(time) => console.log(time)}
|
||||
* />
|
||||
* ```
|
||||
*/
|
||||
export const WaitBox = defineComponent<
|
||||
WaitBoxProps<unknown>,
|
||||
WaitBoxEmits<unknown>,
|
||||
keyof WaitBoxEmits<unknown>
|
||||
>(
|
||||
<T,>(
|
||||
props: WaitBoxProps<T>,
|
||||
{ emit, expose, attrs }: SetupContext<WaitBoxEmits<T>>
|
||||
) => {
|
||||
const contentHeight = ref(200);
|
||||
|
||||
const text = computed(() => props.text ?? '请等待 ...');
|
||||
const pad = computed(() => props.pad ?? 24);
|
||||
const containerLoc = computed<ElementLocator>(() => {
|
||||
const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc;
|
||||
return [x, y, props.width, contentHeight.value, ax, ay];
|
||||
});
|
||||
const backLoc = computed<ElementLocator>(() => {
|
||||
return [1, 1, props.width - 2, contentHeight.value - 2];
|
||||
});
|
||||
const contentLoc = computed<ElementLocator>(() => {
|
||||
return [
|
||||
pad.value,
|
||||
pad.value,
|
||||
props.width - pad.value * 2,
|
||||
contentHeight.value - pad.value * 2
|
||||
];
|
||||
});
|
||||
|
||||
let resolved: boolean = false;
|
||||
|
||||
props.promise?.then(
|
||||
value => {
|
||||
resolve(value);
|
||||
},
|
||||
reason => {
|
||||
logger.warn(63, reason);
|
||||
}
|
||||
);
|
||||
|
||||
const resolve = (data: T) => {
|
||||
if (resolved) return;
|
||||
resolved = true;
|
||||
emit('resolve', data);
|
||||
};
|
||||
|
||||
const onContentHeight = (height: number) => {
|
||||
contentHeight.value = height + pad.value * 2;
|
||||
};
|
||||
|
||||
expose<WaitBoxExpose<T>>({ resolve });
|
||||
|
||||
return () => (
|
||||
<container loc={containerLoc.value}>
|
||||
<Background
|
||||
loc={backLoc.value}
|
||||
zIndex={0}
|
||||
winskin={props.winskin}
|
||||
color={props.color}
|
||||
border={props.border}
|
||||
/>
|
||||
<TextContent
|
||||
{...attrs}
|
||||
autoHeight
|
||||
text={text.value}
|
||||
loc={contentLoc.value}
|
||||
width={props.width - pad.value * 2}
|
||||
zIndex={5}
|
||||
onUpdateHeight={onContentHeight}
|
||||
/>
|
||||
</container>
|
||||
);
|
||||
},
|
||||
waitBoxProps
|
||||
);
|
||||
|
||||
/**
|
||||
* 打开一个等待框,等待传入的 Promise 兑现后,关闭等待框,并将兑现值返回。
|
||||
* 示例,等待 1000ms:
|
||||
* ```ts
|
||||
* // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻
|
||||
* const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000));
|
||||
* const value = await waitbox(
|
||||
* // 在哪个 UI 控制器上打开,对于一般 UI 组件来说,直接填写 props.controller 即可
|
||||
* props.controller,
|
||||
* // 确认框的位置,宽度由下一个参数指定,高度参数由组件内部计算得出,指定无效
|
||||
* [240, 240, void 0, void 0, 0.5, 0.5],
|
||||
* // 确认框的宽度
|
||||
* 240,
|
||||
* // 要等待的 Promise
|
||||
* promise,
|
||||
* // 额外的 props,例如填写等待文本,此项可选,参考 WaitBoxProps
|
||||
* { text: '请等待 1000ms' }
|
||||
* );
|
||||
* console.log(value); // 输出时间刻
|
||||
* ```
|
||||
* @param controller UI 控制器
|
||||
* @param loc 等待框的位置
|
||||
* @param width 等待框的宽度
|
||||
* @param promise 要等待的 Promise
|
||||
* @param props 额外的 props,参考 {@link WaitBoxProps}
|
||||
*/
|
||||
export function waitbox<T>(
|
||||
controller: IUIController<IGameUIPropsBase>,
|
||||
loc: ElementLocator,
|
||||
width: number,
|
||||
promise: Promise<T>,
|
||||
props?: Partial<WaitBoxProps<T>>
|
||||
): Promise<T> {
|
||||
return new Promise<T>(res => {
|
||||
const instance = controller.open(WaitBoxUI, {
|
||||
...(props ?? {}),
|
||||
loc,
|
||||
width,
|
||||
promise,
|
||||
onResolve: (data: unknown) => {
|
||||
controller.close(instance);
|
||||
res(data as T);
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export const WaitBoxUI = new GameUI('wait-box', WaitBox);
|
||||
Loading…
Reference in New Issue
Block a user