refactor: 按键设置界面

This commit is contained in:
unanmed 2026-10-26 22:20:24 +08:00
parent c5b0a255ba
commit 6369f111e9
4 changed files with 452 additions and 0 deletions

View File

@ -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<string, (readonly KeyCode[])[]> = new Map();
for (const command of this.system.iterateCommands()) {

View File

@ -42,4 +42,9 @@ export interface IClientHotkey extends ISaveableContent<IClientHotkeySave> {
* 获取未分组的按键列表
*/
getUngrouped(): Iterable<IHotkeyCommand>;
/**
* 迭代所有的按键分组
*/
iterateGroups(): Iterable<[id: string, group: IHotkeyCommandGroup]>;
}

View File

@ -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(/<g-rect[\s>]/);
expect(source).not.toContain('<Background');
});
// 每个按键组合渲染一个独立框
it('renders one clickable box per key combination', () => {
expect(hotkeySource).toContain('iterateKeyCombinations(');
});
// 关闭路径使用公共退出命令与返回文本
it('closes through the common exit command and the back text', () => {
expect(hotkeySource).toContain('@common/exit');
expect(hotkeySource).toContain('返回');
});
});

338
src/client/ui/hotkey.tsx Normal file
View File

@ -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<HotkeySettingsProps>;
/** 热键设置界面,左栏切换分组,右栏为每条指令的每个按键组合显示一个可点击框 */
export const HotkeySettings = defineComponent<HotkeySettingsProps>(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<IHotkeyCommand | null>(null);
const selectedIndex = ref(-1);
const heldKeys = ref<KeyCode[]>([]);
const combinationLists = ref<KeyCode[][][]>([]);
/** 将按键组合格式化为显示文本,空组合显示为未设置 */
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 () => (
<container loc={FULL_LOC}>
<text
text="返回"
loc={[LEFT_PAD, TOP_PAD]}
cursor="pointer"
onClick={close}
font={CONTENT_FONT}
fillStyle="#ffffff"
/>
<Scroll loc={[LEFT_PAD, LIST_TOP, GROUP_WIDTH, LIST_HEIGHT]}>
<container
loc={[
0,
0,
GROUP_WIDTH,
Math.max(groups.length, 1) * GROUP_ROW_HEIGHT
]}
>
{groups.map(([id, group], groupIndex) => {
const isSelected = id === selectedGroup.value;
return (
<container
key={id}
loc={[
0,
groupIndex * GROUP_ROW_HEIGHT,
GROUP_WIDTH,
GROUP_ROW_HEIGHT
]}
cursor="pointer"
onClick={() => selectGroup(id)}
>
{isSelected && (
<Selection
loc={[
0,
0,
GROUP_WIDTH,
GROUP_ROW_HEIGHT
]}
border="gold"
noevent
/>
)}
<text
text={group.name}
loc={[8, GROUP_ROW_HEIGHT / 2]}
anc={[0, 0.5]}
font={CONTENT_FONT}
fillStyle={isSelected ? 'gold' : '#ffffff'}
/>
</container>
);
})}
</container>
</Scroll>
<Scroll loc={[RIGHT_LEFT, LIST_TOP, RIGHT_WIDTH, LIST_HEIGHT]}>
<container loc={[0, 0, RIGHT_WIDTH, getContentHeight()]}>
{commands.value.map((command, commandIndex) => {
const list = combinationLists.value[commandIndex] ?? [];
return (
<container
key={command.command}
loc={[
0,
getCommandTop(commandIndex),
RIGHT_WIDTH,
getCommandHeight(command)
]}
>
<text
text={command.name}
loc={[8, 12]}
anc={[0, 0]}
font={CONTENT_FONT}
fillStyle="#ffffff"
/>
<container
loc={[
RIGHT_WIDTH - KEY_BOX_WIDTH - LEFT_PAD,
0,
KEY_BOX_WIDTH,
list.length * KEY_BOX_HEIGHT
]}
>
{list.map((keys, index) => {
const isSelected =
selectedCommand.value === command &&
selectedIndex.value === index;
const showing =
isSelected &&
heldKeys.value.length > 0
? heldKeys.value
: keys;
return (
<container
key={index}
loc={[
0,
index * KEY_BOX_HEIGHT,
KEY_BOX_WIDTH,
KEY_BOX_HEIGHT
]}
cursor="pointer"
onClick={() =>
selectKey(command, index)
}
>
<g-rectr
loc={[
0,
0,
KEY_BOX_WIDTH,
KEY_BOX_HEIGHT - 4
]}
circle={[4]}
fill
fillStyle="rgba(255, 255, 255, 0.08)"
/>
{isSelected && (
<Selection
loc={[
0,
0,
KEY_BOX_WIDTH,
KEY_BOX_HEIGHT - 4
]}
border="gold"
noevent
/>
)}
<text
text={formatCombination(
showing
)}
loc={[
KEY_BOX_WIDTH / 2,
(KEY_BOX_HEIGHT - 4) / 2
]}
anc={[0.5, 0.5]}
font={CONTENT_FONT}
fillStyle={
isSelected
? 'gold'
: '#ffffff'
}
/>
</container>
);
})}
</container>
</container>
);
})}
</container>
</Scroll>
</container>
);
}, hotkeySettingsProps);
/** 热键设置界面的 UI 控制器 */
export const HotkeySettingsUI = new GameUI('hotkey-settings', HotkeySettings);