refactor: UI 换用新的按键系统

This commit is contained in:
unanmed 2026-10-26 18:45:07 +08:00
parent 2f1e3f5b70
commit 85d4b9f610
13 changed files with 1135 additions and 750 deletions

View File

@ -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';

View File

@ -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);

View File

@ -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);

View File

@ -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
}

View 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');
});
});

View File

@ -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);

View File

@ -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
View 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);

View File

@ -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 事件监听

View File

@ -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);

View File

@ -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 鼠标操作

View File

@ -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
View 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);