diff --git a/packages-user/client-base/src/components/index.ts b/packages-user/client-base/src/components/index.ts index 42e2969..c05e562 100644 --- a/packages-user/client-base/src/components/index.ts +++ b/packages-user/client-base/src/components/index.ts @@ -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'; diff --git a/packages-user/client-base/src/components/input.tsx b/packages-user/client-base/src/components/input.tsx index 83a99f4..6da3818 100644 --- a/packages-user/client-base/src/components/input.tsx +++ b/packages-user/client-base/src/components/input.tsx @@ -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 { /** 输入框的提示内容 */ @@ -221,14 +218,6 @@ export const Input = defineComponent( 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 ); -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); - * - * - * ``` - */ -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(() => [ - padding.value, - padding.value * 2 + contentHeight.value, - props.width - padding.value * 2, - inputHeight.value - ]); - const yesLoc = computed(() => { - const y = height.value - padding.value; - return [props.width / 3, y, void 0, void 0, 0.5, 1]; - }); - const noLoc = computed(() => { - const y = height.value - padding.value; - return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1]; - }); - const selectionLoc = computed(() => { - 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(() => { - 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 () => ( - - - - - - (selected.value = true)} - onResize={setYes} - /> - (selected.value = false)} - onResize={setNo} - /> - - ); -}, 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, - text: string, - loc: ElementLocator, - width: number, - props?: InputBoxProps -) { - return new Promise(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, - 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); diff --git a/packages-user/client-base/src/components/misc.tsx b/packages-user/client-base/src/components/misc.tsx index 0ef9587..365e826 100644 --- a/packages-user/client-base/src/components/misc.tsx +++ b/packages-user/client-base/src/components/misc.tsx @@ -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(props => { /> ); }, selectionProps); - -export interface BackgroundProps extends DefaultProps { - loc: ElementLocator; - winskin?: ImageIds; - color?: CanvasStyle; - border?: CanvasStyle; -} - -const backgroundProps = { - props: ['loc', 'winskin', 'color', 'border'] -} satisfies SetupComponentOptions; - -/** - * 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下: - * ```tsx - * // 使用 winskin2.png 作为背景 - * - * // 使用指定填充和边框颜色作为背景 - * - * ``` - */ -export const Background = defineComponent(props => { - const isWinskin = computed(() => !!props.winskin); - const fixedLoc = computed(() => { - const [x = 0, y = 0, width = 200, height = 200] = props.loc; - return [x + 2, y + 2, width - 4, height - 4]; - }); - - return () => - isWinskin.value ? ( - - ) : ( - - ); -}, backgroundProps); - -export interface WaitBoxProps - extends Partial, Partial { - loc: ElementLocator; - width: number; - promise?: Promise; - text?: string; - pad?: number; -} - -export type WaitBoxEmits = { - resolve: (data: T) => void; -}; - -export interface WaitBoxExpose { - /** - * 手动将此组件兑现,注意调用时如果传入的 Promise 还没有兑现, - * 当 Promise 兑现后将不会再次触发 resolve 事件,即 resolve 事件只会被触发一次 - * @param data 兑现值 - */ - resolve(data: T): void; -} - -const waitBoxProps = { - props: ['promise', 'loc', 'winskin', 'color', 'border', 'width'], - emits: ['resolve'] -} satisfies SetupComponentOptions< - WaitBoxProps, - WaitBoxEmits, - keyof WaitBoxEmits ->; - -/** - * 等待框,可以等待某个异步操作 (Promise),操作完毕后触发兑现事件,单次调用参考 {@link waitbox}。 - * 参数参考 {@link WaitBoxProps},事件参考 {@link WaitBoxEmits},函数接口参考 {@link WaitBoxExpose}。用例如下: - * ```tsx - * // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻 - * const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000)); - * - * console.log(time)} - * /> - * ``` - */ -export const WaitBox = defineComponent< - WaitBoxProps, - WaitBoxEmits, - keyof WaitBoxEmits ->( - ( - props: WaitBoxProps, - { emit, expose, attrs }: SetupContext> - ) => { - const contentHeight = ref(200); - - const text = computed(() => props.text ?? '请等待 ...'); - const pad = computed(() => props.pad ?? 24); - const containerLoc = computed(() => { - const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc; - return [x, y, props.width, contentHeight.value, ax, ay]; - }); - const backLoc = computed(() => { - return [1, 1, props.width - 2, contentHeight.value - 2]; - }); - const contentLoc = computed(() => { - 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>({ resolve }); - - return () => ( - - - - - ); - }, - 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( - controller: IUIController, - loc: ElementLocator, - width: number, - promise: Promise, - props?: Partial> -): Promise { - return new Promise(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); diff --git a/src/client/misc/hotkey.ts b/src/client/misc/hotkey.ts index 7a570cd..6e884ab 100644 --- a/src/client/misc/hotkey.ts +++ b/src/client/misc/hotkey.ts @@ -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 } diff --git a/src/client/ui/__test__/keyAdaptationGate.test.ts b/src/client/ui/__test__/keyAdaptationGate.test.ts new file mode 100644 index 0000000..a2f3f2f --- /dev/null +++ b/src/client/ui/__test__/keyAdaptationGate.test.ts @@ -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('' 到下一条命令或分组结束后 +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('' 到下一条 .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(/])/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'); + }); +}); diff --git a/src/client/ui/background.tsx b/src/client/ui/background.tsx index 228e3e6..854c76e 100644 --- a/src/client/ui/background.tsx +++ b/src/client/ui/background.tsx @@ -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(() => { /** 主 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; + +/** + * 背景组件,与 Selection 类似,不过绘制的是背景,而不是选择光标,参数参考 {@link BackgroundProps},用例如下: + * ```tsx + * // 使用 winskin2.png 作为背景 + * + * // 使用指定填充和边框颜色作为背景 + * + * ``` + */ +export const Background = defineComponent(props => { + const isWinskin = computed(() => !!props.winskin); + const fixedLoc = computed(() => { + const [x = 0, y = 0, width = 200, height = 200] = props.loc; + return [x + 2, y + 2, width - 4, height - 4]; + }); + + return () => + isWinskin.value ? ( + + ) : ( + + ); +}, backgroundProps); + +export const BackgroundUI = new GameUI('background', Background); diff --git a/src/client/ui/choices.tsx b/src/client/ui/choices.tsx index 939f8f4..1fbd21d 100644 --- a/src/client/ui/choices.tsx +++ b/src/client/ui/choices.tsx @@ -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]); diff --git a/src/client/ui/inputBox.tsx b/src/client/ui/inputBox.tsx new file mode 100644 index 0000000..3eb2462 --- /dev/null +++ b/src/client/ui/inputBox.tsx @@ -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); + * + * + * ``` + */ +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(() => [ + padding.value, + padding.value * 2 + contentHeight.value, + props.width - padding.value * 2, + inputHeight.value + ]); + const yesLoc = computed(() => { + const y = height.value - padding.value; + return [props.width / 3, y, void 0, void 0, 0.5, 1]; + }); + const noLoc = computed(() => { + const y = height.value - padding.value; + return [(props.width / 3) * 2, y, void 0, void 0, 0.5, 1]; + }); + const selectionLoc = computed(() => { + 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(() => { + 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 () => ( + + + + + + (selected.value = true)} + onResize={setYes} + /> + (selected.value = false)} + onResize={setNo} + /> + + ); +}, 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, + text: string, + loc: ElementLocator, + width: number, + props?: InputBoxProps +) { + return new Promise(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, + 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); diff --git a/src/client/ui/save.tsx b/src/client/ui/save.tsx index 17d1968..ef00d65 100644 --- a/src/client/ui/save.tsx +++ b/src/client/ui/save.tsx @@ -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; @@ -299,8 +300,8 @@ export const Save = defineComponent( // #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( (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 事件监听 diff --git a/src/client/ui/settings.tsx b/src/client/ui/settings.tsx index 6c71033..662caea 100644 --- a/src/client/ui/settings.tsx +++ b/src/client/ui/settings.tsx @@ -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, IUIPropsBase { +// 这个类型有点离谱了,换成新的设置系统之后要改 +export interface MainSettingsProps + extends + Partial>, + IGameUIPropsBase { loc: ElementLocator; } @@ -54,8 +58,8 @@ export const MainSettings = defineComponent(props => { ]; const controller: IUIController = 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(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(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(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(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(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(props => { ]; const controller: IUIController = 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(props => { choices={choices} onChoose={choose} interval={8} - scope={scope} + freshScope={false} + state={props.state!} /> ); }, mainSettingsProps); @@ -418,8 +426,8 @@ export const SyncSaveSelect = defineComponent(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(props => { choices={choices} onChoose={choose} interval={8} - scope={scope} + freshScope={false} + state={props.state!} /> ); }, mainSettingsProps); @@ -475,8 +484,8 @@ export const DownloadSaveSelect = defineComponent(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(props => { choices={choices} onChoose={choose} interval={8} - scope={scope} + freshScope={false} + state={props.state!} /> ); }, mainSettingsProps); @@ -548,8 +558,8 @@ export const ClearSaveSelect = defineComponent(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(props => { choices={choices} onChoose={choose} interval={8} - scope={scope} + freshScope={false} + state={props.state!} /> ); }, mainSettingsProps); diff --git a/src/client/ui/title.tsx b/src/client/ui/title.tsx index a6c88fc..9658447 100644 --- a/src/client/ui/title.tsx +++ b/src/client/ui/title.tsx @@ -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(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 鼠标操作 diff --git a/src/client/ui/viewmap.tsx b/src/client/ui/viewmap.tsx index 470e71f..5867e79 100644 --- a/src/client/ui/viewmap.tsx +++ b/src/client/ui/viewmap.tsx @@ -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; export const ViewMap = defineComponent(props => { @@ -90,23 +84,16 @@ export const ViewMap = defineComponent(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 功能函数 diff --git a/src/client/ui/waitBox.tsx b/src/client/ui/waitBox.tsx new file mode 100644 index 0000000..3d5ad7f --- /dev/null +++ b/src/client/ui/waitBox.tsx @@ -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 + extends Partial, Partial { + loc: ElementLocator; + width: number; + promise?: Promise; + text?: string; + pad?: number; +} + +export type WaitBoxEmits = { + resolve: (data: T) => void; +}; + +export interface WaitBoxExpose { + /** + * 手动将此组件兑现,注意调用时如果传入的 Promise 还没有兑现, + * 当 Promise 兑现后将不会再次触发 resolve 事件,即 resolve 事件只会被触发一次 + * @param data 兑现值 + */ + resolve(data: T): void; +} + +const waitBoxProps = { + props: ['promise', 'loc', 'winskin', 'color', 'border', 'width'], + emits: ['resolve'] +} satisfies SetupComponentOptions< + WaitBoxProps, + WaitBoxEmits, + keyof WaitBoxEmits +>; + +/** + * 等待框,可以等待某个异步操作 (Promise),操作完毕后触发兑现事件,单次调用参考 {@link waitbox}。 + * 参数参考 {@link WaitBoxProps},事件参考 {@link WaitBoxEmits},函数接口参考 {@link WaitBoxExpose}。用例如下: + * ```tsx + * // 创建一个等待 1000ms 的 Promise,兑现值是等待完毕时的当前时间刻 + * const promise = new Promise(res => window.setTimeout(() => res(Date.now()), 1000)); + * + * console.log(time)} + * /> + * ``` + */ +export const WaitBox = defineComponent< + WaitBoxProps, + WaitBoxEmits, + keyof WaitBoxEmits +>( + ( + props: WaitBoxProps, + { emit, expose, attrs }: SetupContext> + ) => { + const contentHeight = ref(200); + + const text = computed(() => props.text ?? '请等待 ...'); + const pad = computed(() => props.pad ?? 24); + const containerLoc = computed(() => { + const [x = 0, y = 0, , , ax = 0, ay = 0] = props.loc; + return [x, y, props.width, contentHeight.value, ax, ay]; + }); + const backLoc = computed(() => { + return [1, 1, props.width - 2, contentHeight.value - 2]; + }); + const contentLoc = computed(() => { + 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>({ resolve }); + + return () => ( + + + + + ); + }, + 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( + controller: IUIController, + loc: ElementLocator, + width: number, + promise: Promise, + props?: Partial> +): Promise { + return new Promise(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);