diff --git a/packages-user/client-base/src/hotkey/hotkey.ts b/packages-user/client-base/src/hotkey/hotkey.ts index 5640abd..9344fab 100644 --- a/packages-user/client-base/src/hotkey/hotkey.ts +++ b/packages-user/client-base/src/hotkey/hotkey.ts @@ -46,6 +46,10 @@ export class ClientHotkey implements IClientHotkey { return new Set([...this.system.iterateCommands()]).difference(grouped); } + iterateGroups(): Iterable<[id: string, group: IHotkeyCommandGroup]> { + return this.groups.entries(); + } + saveState(): IClientHotkeySave { const keys: Map = new Map(); for (const command of this.system.iterateCommands()) { diff --git a/packages-user/client-base/src/hotkey/types.ts b/packages-user/client-base/src/hotkey/types.ts index c885257..d1cdf0c 100644 --- a/packages-user/client-base/src/hotkey/types.ts +++ b/packages-user/client-base/src/hotkey/types.ts @@ -42,4 +42,9 @@ export interface IClientHotkey extends ISaveableContent { * 获取未分组的按键列表 */ getUngrouped(): Iterable; + + /** + * 迭代所有的按键分组 + */ + iterateGroups(): Iterable<[id: string, group: IHotkeyCommandGroup]>; } diff --git a/src/client/ui/__test__/hotkeySettingsGate.test.ts b/src/client/ui/__test__/hotkeySettingsGate.test.ts new file mode 100644 index 0000000..014aba8 --- /dev/null +++ b/src/client/ui/__test__/hotkeySettingsGate.test.ts @@ -0,0 +1,105 @@ +import { describe, expect, it } from 'vitest'; +import hotkeySource from '../hotkey.tsx?raw'; +import typesSource from '../../../../packages-user/client-base/src/hotkey/types.ts?raw'; +import implSource from '../../../../packages-user/client-base/src/hotkey/hotkey.ts?raw'; +import settingsSource from '../settings.tsx?raw'; +import uiIndexSource from '../index.ts?raw'; + +// 抹掉块注释、JSX 注释与行注释,避免说明性文字让否定断言假红 +function stripComments(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('热键设置源码闸门', () => { + // iterateGroups 必须同时存在于接口声明与实现中 + it('declares iterateGroups on the interface and returns the group entries', () => { + expect(typesSource).toMatch(/\/\*\*[\s\S]*?\*\/\s*iterateGroups\(\)/); + expect(typesSource).toMatch(/iterateGroups\(\)\s*:\s*Iterable/); + expect(implSource).toMatch( + /iterateGroups\(\)[\s\S]*?this\.groups\.entries\(\)/ + ); + }); + + // 界面不得包含任何持久化调用 + it('keeps persistence calls out of the hotkey settings ui', () => { + const source = stripComments(hotkeySource); + expect(source).not.toContain('saveState'); + expect(source).not.toContain('setGlobal'); + expect(source).not.toContain('loadState'); + }); + + // 设置入口与 UI 导出清单都不得接入本界面 + it('leaves the settings entry and ui barrel untouched', () => { + expect(settingsSource).not.toContain('HotkeySettings'); + expect(settingsSource).not.toContain('hotkey.tsx'); + expect(uiIndexSource).not.toContain('./hotkey'); + }); + + // 文档按键监听必须位于捕获阶段,并阻断事件继续传播 + it('listens on document in the capture phase and blocks propagation', () => { + expect(hotkeySource).toMatch( + /addEventListener\(\s*'keydown'\s*,[^,]+,\s*true\s*\)/ + ); + expect(hotkeySource).toMatch( + /addEventListener\(\s*'keyup'\s*,[^,]+,\s*true\s*\)/ + ); + expect(hotkeySource).toContain('preventDefault()'); + expect(hotkeySource).toContain('stopPropagation()'); + expect(hotkeySource).toContain('stopImmediatePropagation()'); + }); + + // 组合必须写回指令对象 + it('writes the captured combination back through setKeyCombination', () => { + expect(hotkeySource).toContain('setKeyCombination('); + }); + + // 组合文本使用统一枚举转换,并以加号连接,空组合显示未设置 + it('formats combinations with KeyCodeUtils and the plus separator', () => { + expect(hotkeySource).toContain('KeyCodeUtils.toString'); + expect(hotkeySource).toContain(' + '); + expect(hotkeySource).toContain('未设置'); + }); + + // 不加入冲突检测与恢复默认 + it('adds neither conflict detection nor default restoring', () => { + const source = stripComments(hotkeySource).toLowerCase(); + expect(source).not.toContain('conflict'); + expect(source).not.toContain('restoredefault'); + expect(source).not.toContain('resetdefault'); + }); + + // 左栏分组必须来自分组迭代 API + it('builds the group list from iterateGroups', () => { + expect(hotkeySource).toContain('iterateGroups('); + }); + + // 事件码必须经同一映射表转换,并忽略未知按键 + it('maps events through EVENT_KEY_CODE_MAP and ignores unknown keys', () => { + expect(hotkeySource).toContain('EVENT_KEY_CODE_MAP'); + expect(hotkeySource).toContain('KeyCode.Unknown'); + }); + + // 全屏布局且不自绘背景 + it('uses the full screen locator and draws no background', () => { + expect(hotkeySource).toContain('FULL_LOC'); + const source = stripComments(hotkeySource); + expect(source).not.toMatch(/]/); + expect(source).not.toContain(' { + expect(hotkeySource).toContain('iterateKeyCombinations('); + }); + + // 关闭路径使用公共退出命令与返回文本 + it('closes through the common exit command and the back text', () => { + expect(hotkeySource).toContain('@common/exit'); + expect(hotkeySource).toContain('返回'); + }); +}); diff --git a/src/client/ui/hotkey.tsx b/src/client/ui/hotkey.tsx new file mode 100644 index 0000000..1392ced --- /dev/null +++ b/src/client/ui/hotkey.tsx @@ -0,0 +1,338 @@ +import { + EVENT_KEY_CODE_MAP, + GameUI, + IHotkeyCommand, + KeyCode, + KeyCodeUtils, + SetupComponentOptions +} from '@motajs/system'; +import { Font } from '@motajs/render'; +import { computed, defineComponent, onUnmounted, ref, shallowRef } from 'vue'; +import { IGameUIPropsBase, Scroll, Selection, useKey } from '@user/client-base'; +import { FULL_LOC, MAIN_HEIGHT, MAIN_WIDTH } from '@/client/uiConstant'; + +/** 界面左侧与顶部的内边距 */ +const LEFT_PAD = 16; +const TOP_PAD = 16; +/** 左侧分组列表的宽度 */ +const GROUP_WIDTH = 180; +/** 左侧每一行分组的高度 */ +const GROUP_ROW_HEIGHT = 24; +/** 左侧列表区域的起始纵坐标 */ +const LIST_TOP = TOP_PAD + 32; +/** 右侧指令区域的起始横坐标 */ +const RIGHT_LEFT = GROUP_WIDTH + LEFT_PAD * 2; +/** 右侧指令区域的宽度 */ +const RIGHT_WIDTH = MAIN_WIDTH - RIGHT_LEFT - LEFT_PAD; +/** 左右两栏列表区域的高度,底部留出返回按钮的空间 */ +const LIST_HEIGHT = MAIN_HEIGHT - LIST_TOP - 48; +/** 单个按键框的宽度 */ +const KEY_BOX_WIDTH = 180; +/** 单个按键框的高度 */ +const KEY_BOX_HEIGHT = 24; +/** 指令之间的纵向间隔 */ +const COMMAND_GAP = 12; +/** 界面内所有文本共用的字体 */ +const CONTENT_FONT = Font.defaults({ size: 13 }); + +export interface HotkeySettingsProps extends IGameUIPropsBase {} + +/** 热键设置组件的 props 声明 */ +const hotkeySettingsProps = { + props: ['controller', 'instance', 'state'] +} satisfies SetupComponentOptions; + +/** 热键设置界面,左栏切换分组,右栏为每条指令的每个按键组合显示一个可点击框 */ +export const HotkeySettings = defineComponent(props => { + const hotkey = props.state.hotkey; + const groups = Array.from(hotkey.iterateGroups()); + const selectedGroup = ref(groups.length > 0 ? groups[0][0] : ''); + const commands = computed(() => { + const group = hotkey.getGroup(selectedGroup.value); + return group ? group.commands : []; + }); + const selectedCommand = shallowRef(null); + const selectedIndex = ref(-1); + const heldKeys = ref([]); + const combinationLists = ref([]); + + /** 将按键组合格式化为显示文本,空组合显示为未设置 */ + const formatCombination = (keys: readonly KeyCode[]): string => { + if (keys.length === 0) return '未设置'; + return keys.map(key => KeyCodeUtils.toString(key)).join(' + '); + }; + + /** 重建按键组合快照,指令实例不是响应式的,不重建会让写回后的界面仍显示旧组合 */ + const refreshCombinations = (): void => { + const lists: KeyCode[][][] = []; + for (const command of commands.value) { + const keys: KeyCode[][] = []; + for (const pair of command.iterateKeyCombinations()) { + keys.push(pair[1].slice()); + } + lists.push(keys); + } + combinationLists.value = lists; + }; + + /** 计算一条指令占用的高度,同一指令的多个组合纵向堆叠 */ + const getCommandHeight = (command: IHotkeyCommand): number => { + return Math.max(command.length, 1) * KEY_BOX_HEIGHT + COMMAND_GAP; + }; + + /** 计算第 index 条指令的顶部纵坐标 */ + const getCommandTop = (index: number): number => { + let top = 0; + for (let i = 0; i < index; i++) { + top += getCommandHeight(commands.value[i]); + } + return top; + }; + + /** 计算当前分组全部指令占用的总高度,作为滚动区域的内容高度 */ + const getContentHeight = (): number => { + let height = 0; + for (const command of commands.value) { + height += getCommandHeight(command); + } + return height; + }; + + /** 移除文档上的按键监听 */ + const removeCapture = (): void => { + document.removeEventListener('keydown', onKeyDown, true); + document.removeEventListener('keyup', onKeyUp, true); + }; + + /** 取消捕获并清空选中态 */ + const cancelCapture = (): void => { + removeCapture(); + selectedCommand.value = null; + selectedIndex.value = -1; + heldKeys.value = []; + }; + + /** 切换分组后右栏指令随之切换,并取消当前捕获 */ + const selectGroup = (id: string): void => { + cancelCapture(); + selectedGroup.value = id; + refreshCombinations(); + }; + + /** 选中一个按键框,并以捕获阶段监听文档按键事件 */ + const selectKey = (command: IHotkeyCommand, index: number): void => { + cancelCapture(); + selectedCommand.value = command; + selectedIndex.value = index; + document.addEventListener('keydown', onKeyDown, true); + document.addEventListener('keyup', onKeyUp, true); + }; + + /** 捕获阶段处理按下事件,忽略重复与无法映射的按键 */ + const onKeyDown = (event: KeyboardEvent): void => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + if (event.repeat) return; + const code = EVENT_KEY_CODE_MAP[event.keyCode]; + if (code === KeyCode.Unknown || !code) return; + if (!heldKeys.value.includes(code)) heldKeys.value.push(code); + }; + + /** 捕获阶段处理抬起事件,首次抬起结束捕获并以抬起键作为主键 */ + const onKeyUp = (event: KeyboardEvent): void => { + event.preventDefault(); + event.stopPropagation(); + event.stopImmediatePropagation(); + const code = EVENT_KEY_CODE_MAP[event.keyCode]; + if (code === KeyCode.Unknown || !code) return; + const command = selectedCommand.value; + if (!command) { + cancelCapture(); + return; + } + const combination = heldKeys.value.filter(key => key !== code); + combination.push(code); + command.setKeyCombination(selectedIndex.value, combination); + refreshCombinations(); + cancelCapture(); + }; + + onUnmounted(cancelCapture); + refreshCombinations(); + + const key = useKey(props.state.hotkey.system)!; + key.implement('@common/exit', () => { + if (selectedCommand.value) return; + props.controller.close(props.instance); + }); + + /** 关闭界面,返回文本是实际的关闭路径 */ + const close = (): void => { + cancelCapture(); + props.controller.close(props.instance); + }; + + return () => ( + + + + + {groups.map(([id, group], groupIndex) => { + const isSelected = id === selectedGroup.value; + return ( + selectGroup(id)} + > + {isSelected && ( + + )} + + + ); + })} + + + + + {commands.value.map((command, commandIndex) => { + const list = combinationLists.value[commandIndex] ?? []; + return ( + + + + {list.map((keys, index) => { + const isSelected = + selectedCommand.value === command && + selectedIndex.value === index; + const showing = + isSelected && + heldKeys.value.length > 0 + ? heldKeys.value + : keys; + return ( + + selectKey(command, index) + } + > + + {isSelected && ( + + )} + + + ); + })} + + + ); + })} + + + + ); +}, hotkeySettingsProps); + +/** 热键设置界面的 UI 控制器 */ +export const HotkeySettingsUI = new GameUI('hotkey-settings', HotkeySettings);