refactor: 按键注册

This commit is contained in:
unanmed 2026-10-23 20:41:40 +08:00
parent 343296c63c
commit 0b7eac364f
9 changed files with 1550 additions and 856 deletions

View File

@ -4,28 +4,41 @@ import {
IHotkeySystem,
KeyCode
} from '@motajs/system';
import { IClientHotkey, IClientHotkeySave } from './types';
import { IClientHotkey, IClientHotkeySave, IHotkeyCommandGroup } from './types';
import { logger } from '@motajs/common';
export class ClientHotkey implements IClientHotkey {
system: IHotkeySystem = new HotkeySystem();
/** 按键分组 */
private readonly groups: Map<string, IHotkeyCommand[]> = new Map();
private readonly groups: Map<string, IHotkeyCommandGroup> = new Map();
setGroup(group: string, commands: IHotkeyCommand[]): void {
setGroup(group: string, name: string, commands: IHotkeyCommand[]): void {
if (commands.length > 0) {
if (this.groups.has(group)) {
logger.warn(208, group);
}
this.groups.set(group, commands);
this.groups.set(group, {
name,
commands
});
} else {
this.groups.delete(group);
}
}
getGroup(group: string): IHotkeyCommand[] {
return this.groups.get(group) ?? [];
addCommandToGroup(group: string, command: IHotkeyCommand): void {
const obj = this.groups.get(group);
if (!obj) return;
if (obj.commands.includes(command)) {
logger.warn(210, command.command, group);
return;
}
obj.commands.push(command);
}
getGroup(group: string): IHotkeyCommandGroup | null {
return this.groups.get(group) ?? null;
}
getUngrouped(): Iterable<IHotkeyCommand> {

View File

@ -1,6 +1,13 @@
import { IHotkeyCommand, IHotkeySystem, KeyCode } from '@motajs/system';
import { ISaveableContent } from '@user/data-common';
export interface IHotkeyCommandGroup {
/** 按键分组的名称 */
readonly name: string;
/** 分组包含的按键指令列表 */
readonly commands: IHotkeyCommand[];
}
export interface IClientHotkeySave {
/** 按键指令列表 */
readonly keys: ReadonlyMap<string, readonly (readonly KeyCode[])[]>;
@ -12,16 +19,24 @@ export interface IClientHotkey extends ISaveableContent<IClientHotkeySave> {
/**
* 设置指定分组的按键指令列表,若不存在分组则创建,若传入空数组则删除
* @param group 按键分组名
* @param group 按键分组 id
* @param name 按键分组名称,用于显示
* @param commands 分组中包含的按键指令名
*/
setGroup(group: string, commands: IHotkeyCommand[]): void;
setGroup(group: string, name: string, commands: IHotkeyCommand[]): void;
/**
* 获取指定按键分组中包含的按键,如果不存在则返回空数组
* 向按键分组中添加新的按键指令
* @param group 按键分组 id
* @param command 要添加的按键指令
*/
addCommandToGroup(group: string, command: IHotkeyCommand): void;
/**
* 获取指定按键分组中包含的按键
* @param group 按键分组名
*/
getGroup(group: string): IHotkeyCommand[];
getGroup(group: string): IHotkeyCommandGroup | null;
/**
* 获取未分组的按键列表

View File

@ -299,7 +299,8 @@
"206": "Cannot set command key combination since delivered index is invalid: expected positive integer, found $1.",
"207": "Key combination count needs to be a positive integer, but got $1.",
"208": "Duplicate hotkey command group name: $1, old one will be overwritten.",
"209": "Cannot load key combinations for hotkey command $1 since no command named it.",
"209": "Cannot load key combinations for hotkey command '$1' since no command named it.",
"210": "Cannot add duplicate hotkey command '$1' to group '$2'.",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."
}
}

View File

@ -1,7 +1,7 @@
// 测试按键指令对象的组合管理、模式切换与非法组合告警
// 测试按键指令对象的构造缺省、组合增删、参数校验与模式切换行为
import { logger } from '@motajs/common';
import { afterAll, afterEach, describe, expect, it, vi } from 'vitest';
import { HotkeyCommand, isAuxiliaryKey } from '../command';
import { HotkeyCommand } from '../command';
import { KeyCode } from '../keyCodes';
import { HotkeyEmitMode, IHotkeyCommandInit } from '../type';
@ -30,27 +30,29 @@ function createInit(
};
}
// 构造一个用于测试的按键指令,显示名称与标识符一致
function createCommand(
command: string,
init: IHotkeyCommandInit
): HotkeyCommand {
return new HotkeyCommand(command, command, init);
}
describe('HotkeyCommand', () => {
// 辅助键判定覆盖 Ctrl / Shift / Alt / Meta 四个按键
it('recognizes auxiliary keys', () => {
expect(isAuxiliaryKey(KeyCode.Ctrl)).toBe(true);
expect(isAuxiliaryKey(KeyCode.Shift)).toBe(true);
expect(isAuxiliaryKey(KeyCode.Alt)).toBe(true);
expect(isAuxiliaryKey(KeyCode.Meta)).toBe(true);
});
// 常规按键不会被判定为辅助键
it('rejects regular keys as auxiliary', () => {
expect(isAuxiliaryKey(KeyCode.KeyA)).toBe(false);
expect(isAuxiliaryKey(KeyCode.Digit1)).toBe(false);
expect(isAuxiliaryKey(KeyCode.Space)).toBe(false);
});
// 构造器应用缺省值并将默认组合拷贝进指令
it('applies defaults and copies the default combinations', () => {
const defaults = [[KeyCode.Ctrl, KeyCode.KeyS]];
const command = new HotkeyCommand('save', createInit({ defaults }));
// 构造器应用缺省值并写入默认组合
it('applies defaults on construction', () => {
const command = createCommand(
'save',
createInit({
priority: 7,
defaults: [[KeyCode.Ctrl, KeyCode.KeyS]]
})
);
expect(command.command).toBe('save');
expect(command.name).toBe('save');
expect(command.priority).toBe(7);
expect(command.block).toBe(false);
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(0);
expect(command.interval).toBe(0);
@ -60,29 +62,71 @@ describe('HotkeyCommand', () => {
KeyCode.Ctrl,
KeyCode.KeyS
]);
});
// 未指定优先级时默认为 0
it('defaults priority to zero', () => {
const command = new HotkeyCommand('save', 'save', { defaults: [] });
expect(command.priority).toBe(0);
});
// 构造器拷贝默认组合,调用方后续修改原数组不影响指令内部
it('copies the default combinations', () => {
const defaults = [[KeyCode.Ctrl, KeyCode.KeyS]];
const command = createCommand('save', createInit({ defaults }));
defaults[0].push(KeyCode.KeyA);
expect(command.getKeyCombination(0)).toEqual([
KeyCode.Ctrl,
KeyCode.KeyS
]);
});
// getKeyCombination 返回副本,修改不会影响指令内部组合
it('returns a copy from getKeyCombination', () => {
const command = new HotkeyCommand(
// 默认组合为空数组时既不告警,长度也为 0
it('accepts an empty defaults list without warning', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const command = createCommand('save', createInit());
expect(warn).not.toHaveBeenCalled();
expect(command.length).toBe(0);
});
// setKeys(0) 合法:不告警、缩容到空且迭代结果为空
it('shrinks to zero combinations with setKeys(0)', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
const keys = command.getKeyCombination(0);
keys.push(KeyCode.KeyA);
command.setKeys(0);
expect(warn).not.toHaveBeenCalled();
expect(command.length).toBe(0);
expect([...command.iterateKeyCombinations()]).toEqual([]);
});
// setKeys 对负值与非整数各告警一次 207 并保持原状
it('warns 207 for negative and non-integer counts in setKeys', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.setKeys(-1);
command.setKeys(1.5);
expect(warn.mock.calls.map(c => c[0])).toEqual([207, 207]);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// setKeys 扩容补空组合、缩容截断并同步 length
it('resizes combinations with setKeys', () => {
const command = new HotkeyCommand('save', createInit());
const command = createCommand('save', createInit());
command.setKeys(2);
command.setKeyCombination(0, [KeyCode.KeyS]);
command.setKeyCombination(1, [KeyCode.KeyD]);
@ -97,125 +141,134 @@ describe('HotkeyCommand', () => {
expect(command.getKeyCombination(1)).toEqual([]);
});
// setKeys 忽略负值与非整数
it('ignores invalid counts in setKeys', () => {
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.setKeys(-1);
command.setKeys(1.5);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// setKeyCombination 忽略越界索引
it('ignores out-of-range combination indexes', () => {
const command = new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.setKeyCombination(3, [KeyCode.KeyA]);
command.setKeyCombination(-1, [KeyCode.KeyD]);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
expect(command.getKeyCombination(3)).toEqual([]);
});
// iterateKeyCombinations 按索引升序产出并返回副本
it('iterates combinations in order with copies', () => {
const command = new HotkeyCommand(
'save',
createInit({
defaults: [[KeyCode.KeyS], [KeyCode.Ctrl, KeyCode.KeyD]]
})
);
const entries = [...command.iterateKeyCombinations()];
expect(entries.map(v => v[0])).toEqual([0, 1]);
expect(entries[1][1]).toEqual([KeyCode.Ctrl, KeyCode.KeyD]);
entries[0][1].push(KeyCode.KeyA);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// KeyUp 模式下两个常规键的组合会发一次 203 且组合仍可读取
it('warns 203 once for an invalid KeyUp combination', () => {
// setKeyCombination 对非整数、负数、越界索引各告警一次 206 并保持原状
it('warns 206 for invalid combination indexes', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const command = new HotkeyCommand(
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.setKeyCombination(1.5, [KeyCode.KeyA]);
command.setKeyCombination(-1, [KeyCode.KeyA]);
command.setKeyCombination(3, [KeyCode.KeyA]);
expect(warn.mock.calls.map(c => c[0])).toEqual([206, 206, 206]);
expect(command.length).toBe(1);
expect(command.getKeyCombination(0)).toEqual([KeyCode.KeyS]);
});
// setKeyCombination 写入副本,调用方后续修改原数组不影响指令内部
it('copies the delivered combination', () => {
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
const keys: KeyCode[] = [KeyCode.Ctrl, KeyCode.KeyS];
command.setKeyCombination(0, keys);
keys.push(KeyCode.KeyA);
expect(command.getKeyCombination(0)).toEqual([
KeyCode.Ctrl,
KeyCode.KeyS
]);
});
// KeyUp 模式接受包含多个常规键的组合且不告警
it('accepts multiple regular keys without warning', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyA, KeyCode.KeyB]] })
);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(203, 'save');
expect(warn).not.toHaveBeenCalled();
expect(command.getKeyCombination(0)).toEqual([
KeyCode.KeyA,
KeyCode.KeyB
]);
});
// KeyUp 模式下单个常规键加辅助键不会告警
it('accepts one regular key with auxiliary keys', () => {
// KeyUp 模式接受多个辅助键或空组合且不告警
it('accepts auxiliary and empty combinations without warning', () => {
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
new HotkeyCommand(
'save',
createInit({ defaults: [[KeyCode.Ctrl, KeyCode.KeyS]] })
);
new HotkeyCommand(
'save',
createCommand(
'both',
createInit({ defaults: [[KeyCode.Ctrl, KeyCode.Shift]] })
);
createCommand('empty', createInit({ defaults: [[]] }));
expect(warn).not.toHaveBeenCalled();
});
// keyUp() 切换到 KeyUp 并对已有非法组合只发一次 203
it('switches to KeyUp and warns once for invalid combinations', () => {
const command = new HotkeyCommand(
// getKeyCombination 越界返回空数组,每次调用都返回新的副本
it('returns empty arrays out of range and fresh copies in range', () => {
const command = createCommand(
'save',
createInit({
mode: HotkeyEmitMode.Once,
defaults: [
[KeyCode.KeyA, KeyCode.KeyB],
[KeyCode.KeyC, KeyCode.KeyD]
]
})
createInit({ defaults: [[KeyCode.KeyS]] })
);
const warn = vi.spyOn(logger, 'warn').mockImplementation(() => {});
command.keyUp(120);
const first = command.getKeyCombination(0);
const second = command.getKeyCombination(0);
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(120);
expect(warn).toHaveBeenCalledTimes(1);
expect(warn).toHaveBeenCalledWith(203, 'save');
expect(command.getKeyCombination(5)).toEqual([]);
expect(first).toEqual([KeyCode.KeyS]);
expect(first).not.toBe(second);
});
// 四个模式方法设置模式与时间参数并保留未传入的值
it('updates mode and timing parameters', () => {
const command = new HotkeyCommand(
// iterateKeyCombinations 按索引升序产出,且每次迭代都产出新的副本
it('iterates combinations in order with fresh copies', () => {
const command = createCommand(
'save',
createInit({
defaults: [[KeyCode.KeyS], [KeyCode.Ctrl, KeyCode.KeyD]]
})
);
const entries = [...command.iterateKeyCombinations()];
const again = [...command.iterateKeyCombinations()];
expect(entries.map(v => v[0])).toEqual([0, 1]);
expect(entries[1][1]).toEqual([KeyCode.Ctrl, KeyCode.KeyD]);
expect(entries[0][1]).not.toBe(again[0][1]);
});
// keyUp 与 once 设置对应的模式与首次触发延时,并返回自身
it('switches between KeyUp and Once with a timeout', () => {
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
command.continuous(30, 60);
expect(command.mode).toBe(HotkeyEmitMode.Continuous);
expect(command.interval).toBe(30);
expect(command.timeout).toBe(60);
expect(command.keyUp(120)).toBe(command);
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(120);
command.throttle(10);
expect(command.mode).toBe(HotkeyEmitMode.Throttle);
expect(command.dt).toBe(10);
expect(command.interval).toBe(30);
expect(command.timeout).toBe(60);
command.once(15);
expect(command.once(15)).toBe(command);
expect(command.mode).toBe(HotkeyEmitMode.Once);
expect(command.timeout).toBe(15);
});
command.keyUp(5);
expect(command.mode).toBe(HotkeyEmitMode.KeyUp);
expect(command.timeout).toBe(5);
// continuous 写入三个时间参数,缺省调用时三者归零,并返回自身
it('sets the Continuous timing parameters', () => {
const command = createCommand(
'save',
createInit({ defaults: [[KeyCode.KeyS]] })
);
expect(command.continuous(30, 60, 90)).toBe(command);
expect(command.mode).toBe(HotkeyEmitMode.Continuous);
expect(command.interval).toBe(30);
expect(command.dt).toBe(60);
expect(command.timeout).toBe(90);
command.continuous();
expect(command.interval).toBe(0);
expect(command.dt).toBe(0);
expect(command.timeout).toBe(0);
});
// 直接构造时仍然保留传入的指令标识符
it('keeps the command identifier passed to the constructor', () => {
expect(createCommand('fire', createInit()).command).toBe('fire');
});
});

File diff suppressed because it is too large Load Diff

View File

@ -2,19 +2,6 @@ import { logger } from '@motajs/common';
import { KeyCode } from './keyCodes';
import { HotkeyEmitMode, IHotkeyCommand, IHotkeyCommandInit } from './type';
/**
* 判断按键是否为辅助按键,即 Ctrl / Shift / Alt / Meta 之一
* @param key 按键码
*/
export function isAuxiliaryKey(key: KeyCode): boolean {
return (
key === KeyCode.Ctrl ||
key === KeyCode.Shift ||
key === KeyCode.Alt ||
key === KeyCode.Meta
);
}
export class HotkeyCommand implements IHotkeyCommand {
readonly priority: number;
readonly block: boolean;
@ -29,10 +16,11 @@ export class HotkeyCommand implements IHotkeyCommand {
constructor(
readonly command: string,
readonly name: string,
init: IHotkeyCommandInit
) {
this.command = command;
this.priority = init.priority;
this.priority = init.priority ?? 0;
this.block = init.block ?? false;
this.mode = init.mode ?? HotkeyEmitMode.KeyUp;
this.timeout = init.timeout ?? 0;
@ -50,7 +38,7 @@ export class HotkeyCommand implements IHotkeyCommand {
logger.warn(207, 'float');
return;
}
if (count <= 0) {
if (count < 0) {
logger.warn(207, 'negative integer');
return;
}
@ -80,12 +68,6 @@ export class HotkeyCommand implements IHotkeyCommand {
return;
}
this.keys[index] = keys.slice();
if (this.mode === HotkeyEmitMode.KeyUp) {
const regular = keys.filter(v => !isAuxiliaryKey(v));
if (regular.length >= 2) {
logger.warn(203, this.command);
}
}
}
getKeyCombination(index: number): readonly KeyCode[] {
@ -100,24 +82,27 @@ export class HotkeyCommand implements IHotkeyCommand {
}
}
keyUp(timeout: number = 0): void {
keyUp(timeout: number = 0): this {
this.mode = HotkeyEmitMode.KeyUp;
this.timeout = timeout;
return this;
}
once(timeout: number = 0): void {
once(timeout: number = 0): this {
this.mode = HotkeyEmitMode.Once;
this.timeout = timeout;
return this;
}
continuous(
interval: number = 0,
dt: number = 0,
timeout: number = 0
): void {
): this {
this.mode = HotkeyEmitMode.Continuous;
this.interval = interval;
this.dt = dt;
this.timeout = timeout;
return this;
}
}

View File

@ -1,6 +1,6 @@
import { IExcitable, IExcitation } from '@motajs/animate';
import { logger, matchArray } from '@motajs/common';
import { HotkeyCommand, isAuxiliaryKey } from './command';
import { logger } from '@motajs/common';
import { HotkeyCommand } from './command';
import { EVENT_KEY_CODE_MAP, KeyCode } from './keyCodes';
import {
HotkeyEmitMode,
@ -23,7 +23,7 @@ interface IHotkeyScope {
interface IHotkeyCommandPending {
/** 按键指令对象 */
readonly command: IHotkeyCommand;
/** 按键组合被按下的时刻 */
/** 本次触发计时的起点,按下触发时为按下时刻,KeyUp 触发时为抬起时刻 */
start: number;
/** 按键组合的索引 */
keyIndex: number;
@ -31,6 +31,8 @@ interface IHotkeyCommandPending {
keys: readonly KeyCode[];
/** 按键触发上下文 */
context: IHotkeyEmitContext;
/** 按键组合匹配时刻的条件判定结果 */
matchedWhen: boolean;
/** 当前已经触发了几次 */
emitted: number;
/** 上一次的触发时刻 */
@ -124,18 +126,9 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
*/
private matchKeyUp(keys: readonly KeyCode[], key: KeyCode): boolean {
if (keys.length === 0) return false;
// 抬起按键仅允许一个非辅助按键
const [main, auxiliary] = matchArray(keys, v => isAuxiliaryKey(v));
if (main.length > 1) return false;
// 如果只有一个按键,那么可以触发,哪怕它是辅助按键
if (main.length === 0) {
return auxiliary.length === 1 && auxiliary[0] === key;
}
// 否则需要判断触发的按键
if (main[0] !== key) return false;
return auxiliary.every(v => this.holdingSet.has(v));
// 最后一个按键为主按键,其余为辅助按键
if (keys.at(-1) !== key) return false;
return keys.slice(0, -1).every(v => this.holdingSet.has(v));
}
/**
@ -165,6 +158,7 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
this.scopes.pop();
this.holdingCmds.clear();
this.waitTimeout.clear();
return;
}
const index = this.scopes.findIndex(v => v.symbol === sym);
if (index < 0) {
@ -209,13 +203,17 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
return this.commands.get(command) ?? null;
}
addCommand(command: string, init: IHotkeyCommandInit): IHotkeyCommand {
addCommand(
command: string,
name: string,
init: IHotkeyCommandInit
): IHotkeyCommand {
const existing = this.commands.get(command);
if (existing) {
logger.warn(204, command);
return existing;
}
const instance = new HotkeyCommand(command, init);
const instance = new HotkeyCommand(command, name, init);
this.commands.set(command, instance);
return instance;
}
@ -247,15 +245,16 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
/**
* 触发指定按键指令
* @param command 按键指令对象
* @param pending 按键指令的 pending 信息
* @returns 是否成功执行指令
*/
private emit(pending: IHotkeyCommandPending) {
private emit(pending: IHotkeyCommandPending): boolean {
if (!pending.matchedWhen) return false;
const command = pending.command;
const scope = this.getScope();
if (!scope) return;
if (!scope) return false;
const impl = scope.implements.get(command);
if (!impl) return;
if (!impl) return false;
const info: IHotkeyEmitInfo = {
mode: command.mode,
command,
@ -266,18 +265,33 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
impl(info);
pending.emitted++;
pending.lastEmit = this.excitation?.payload() ?? 0;
return true;
}
/**
* 获取首个能够触发按键指令的按键组合
* 获取能够触发按键指令的按键组合,多组候选时仅返回辅助键精确匹配的那一组
* @param command 按键指令
* @param holding 在按下触发的按键之前,有哪些按键正在被按下
* @param emit 按下的是哪个按键
*/
private findMatchKeys(
command: IHotkeyCommand
command: IHotkeyCommand,
holding: Set<KeyCode>,
emit: KeyCode
): [index: number, readonly KeyCode[]] | [] {
const candidates: [index: number, readonly KeyCode[]][] = [];
for (const [index, keys] of command.iterateKeyCombinations()) {
const match = keys.every(v => this.holdingSet.has(v));
if (match) return [index, keys];
if (keys.at(-1) !== emit) continue;
if (!keys.slice(0, -1).every(v => holding.has(v))) continue;
candidates.push([index, keys]);
}
if (candidates.length === 0) return [];
if (candidates.length === 1) return candidates[0];
// 多组候选时只认辅助键与当前按住键完全一致的那一组
for (const candidate of candidates) {
if (candidate[1].length - 1 === holding.size) {
return candidate;
}
}
return [];
}
@ -293,12 +307,15 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
index: number,
keys: readonly KeyCode[]
) {
const scope = this.getScope();
if (!scope) return;
const pending: IHotkeyCommandPending = {
command,
start: this.excitation?.payload() ?? 0,
keyIndex: index,
keys,
context: this.createContext(),
matchedWhen: scope.condition?.() ?? true,
lastEmit: -Infinity,
emitted: 0
};
@ -309,7 +326,23 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
}
}
/**
* 当前是否应该进行默认行为阻断
*/
private shouldPrevent(): boolean {
const scope = this.getScope();
if (!scope) return false;
for (const [command] of this.holdingCmds) {
const impl = scope.implements.get(command);
if (impl) return true;
}
return false;
}
down(key: KeyCode): boolean {
const scope = this.getScope();
if (!scope) return false;
const beforeHolding = new Set(this.holdingSet);
if (this.holdingSet.has(key)) {
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
@ -318,64 +351,46 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
this.holdingSet.add(key);
}
for (const command of this.commands.values()) {
const match = this.findMatchKeys(command);
const match = this.findMatchKeys(command, beforeHolding, key);
if (match.length === 0) continue;
const [index, keys] = match;
const pending = this.holdingCmds.get(command);
if (pending) {
// 如果之前有过这个按键指令,那么直接修改现有状态
pending.keyIndex = index;
pending.keys = keys;
pending.context = this.createContext();
} else {
// 如果之前没有这个按键指令,那么加入执行列表
if (!pending) {
// 如果之前没有这个按键指令,那么加入执行列表,如果有的话就不管了
this.addPending(command, index, keys);
continue;
}
}
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
return this.shouldPrevent();
}
up(key: KeyCode): boolean {
const scope = this.getScope();
if (!scope) return false;
if (!this.holdingSet.has(key)) return false;
this.holdingSet.delete(key);
const index = this.holding.indexOf(key);
this.holding.splice(index, 1);
const beforeWait = this.waitTimeout.size;
const toEmit: IHotkeyCommandPending[] = [];
// 需要快照
for (const [command, pending] of new Map(this.holdingCmds)) {
// 对于 KeyUp,如果有可以触发的,那么按规则触发
if (command.mode === HotkeyEmitMode.KeyUp) {
const emit = this.matchKeyUp(pending.keys, key);
if (emit) {
toEmit.push(pending);
this.holdingCmds.delete(command);
}
if (!this.matchKeyUp(pending.keys, key)) continue;
toEmit.push(pending);
this.holdingCmds.delete(command);
} else {
// 对于其他按键,如果一个按下组合按键不再满足,那么需要删除或加入 timeout
if (!this.matchKeys(pending.keys)) {
// 松开一个按键,那么并不一定会导致按下指令不再触发
const newMatch = this.findMatchKeys(command);
if (newMatch.length === 0) {
// 没有新的匹配,分两种情况
if (pending.emitted === 0) {
// 还没触发过,加入 timeout
this.waitTimeout.add(pending);
} else {
// 已经触发过了,直接删除
this.holdingCmds.delete(command);
}
} else {
// 如果有新的匹配,那么直接修改状态
const [index, keys] = newMatch;
pending.context = this.createContext();
pending.keyIndex = index;
pending.keys = keys;
}
if (this.matchKeys(pending.keys)) continue;
this.holdingCmds.delete(command);
if (pending.emitted === 0) {
this.waitTimeout.add(pending);
}
}
}
@ -386,18 +401,24 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
let blocking = -Infinity;
for (const pending of toEmit) {
if (pending.command.priority < blocking) break;
if (!pending.matchedWhen) continue;
// 没有实现的按键不会进行阻断
const impl = scope.implements.get(pending.command);
if (!impl) continue;
if (pending.command.block) {
blocking = pending.command.priority;
}
if (pending.command.timeout === 0) {
this.emit(pending);
} else {
// KeyUp 的 timeout 从抬起时刻开始计时
pending.start = this.excitation?.payload() ?? 0;
this.waitTimeout.add(pending);
}
}
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
return this.waitTimeout.size > beforeWait || this.shouldPrevent();
}
//#endregion
@ -430,22 +451,18 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
if (command.mode === HotkeyEmitMode.KeyUp) continue;
// 首次触发
const dt = payload - pending.start;
const dt2 = payload - pending.lastEmit;
if (pending.emitted === 0 && dt < command.timeout) {
this.emit(pending);
if (pending.emitted === 0) {
if (dt >= command.timeout) this.emit(pending);
continue;
}
if (command.mode === HotkeyEmitMode.Continuous) {
if (pending.emitted === 1) {
// 先看第二次触发
if (dt2 >= command.interval) {
this.emit(pending);
}
} else {
// 其他的要看 dt 了
if (dt2 >= command.dt) {
this.emit(pending);
}
}
if (command.mode !== HotkeyEmitMode.Continuous) continue;
const dt2 = payload - pending.lastEmit;
if (pending.emitted === 1) {
// 先看第二次触发
if (dt2 >= command.interval) this.emit(pending);
} else {
// 其他的要看 dt 了
if (dt2 >= command.dt) this.emit(pending);
}
}
}

View File

@ -18,13 +18,13 @@ export const enum HotkeyEmitMode {
export interface IHotkeyEmitContext {
/**
* 指令触发时,当前所有正在按下的按键,按照按下的顺序排列。如果是抬起事件触发的,那么这里面并不会包含触发事件的那个主要按键。
* 注意该成员的含义不是当前时刻的按下的按键,对于连续触发以及 `timeout`,依然会包含触发此指令的时刻按下的按键。
* 注意该成员的含义不是当前时刻的按下的按键,对于连续触发以及 `timeout`,会包含匹配时刻所有按下的按键。
* 也就是说,它并不是当前时刻按下的按键,不要用它获取当前时刻正在按下的按键。参考时间线:
*
* ```txt
* ----------|---------------|---------->
* | timeout |
* 指令触发时刻 指令延时触发
* 指令匹配时刻 指令延时触发
* 记录的是这个 而不是这个
* ```
*/
@ -53,10 +53,17 @@ export interface IHotkeyEmitInfo {
}
export interface IHotkeyCommandInit {
/** 按键指令优先级,目前仅对抬起触发的按键生效 */
readonly priority: number;
/** 默认按键组合,可设置多组 */
/**
* 默认按键组合,可设置多组。每组中,最后一个按键为触发指令的主按键,前面的属于辅助按键,
* 必须先按下这些按键,再触发最后一个按键的按下或抬起才可能触发指令:
*
* - 对于按下事件,先按下主按键,再按下辅助按键不会触发。松开任意一个按键后,如果该指令的按键依然匹配,并不会继续触发。
* - 对于抬起事件,必须在按下所有的辅助按键和主按键后,松开主按键才可触发,松开辅助按键不会触发。
* - 如果触发一个主按键后,有多个按键组合对应的指令会被触发,那么只会触发辅助按键精确匹配的那一个。
*/
readonly defaults: KeyCode[][];
/** 按键指令优先级,目前仅对抬起触发的按键生效,默认为 0 */
readonly priority?: number;
/** 是否阻断优先级精确比此按键指令更低的指令的执行,同优先级的会全部执行,并不会阻断,默认不阻断 */
readonly block?: boolean;
/** 按键触发方式,默认为抬起时触发 */
@ -72,6 +79,9 @@ export interface IHotkeyCommandInit {
export interface IHotkeyCommand {
/** 按键指令的标识符 */
readonly command: string;
/** 按键指令的名称,用于显示 */
readonly name: string;
/** 按键指令优先级,越高越先执行,如果同优先级,则无法保证顺序,目前仅对抬起触发的按键生效 */
readonly priority: number;
/** 触发后,是否阻断优先级比当前优先级更低的按键的触发 */
@ -117,13 +127,13 @@ export interface IHotkeyCommand {
* 抬起后,在经过 `timeout` 时间后触发一次
* @param timeout 抬起后距离首次触发的时间间隔,单位毫秒,默认为 0
*/
keyUp(timeout?: number): void;
keyUp(timeout?: number): this;
/**
* 按下后,在经过 `timeout` 时间后只触发一次,不会连续触发
* @param timeout 按下后距离首次触发的时间间隔,单位毫秒,默认为 0
*/
once(timeout?: number): void;
once(timeout?: number): this;
/**
* 当按下后,在经过 `timeout` 时间后进行一次触发,之后在松开前,会按照激励源的激励触发
@ -132,7 +142,7 @@ export interface IHotkeyCommand {
* @param dt 节流间隔,默认为 0,即每次激励源的激励都会触发
* @param timeout 按下后距离首次触发的时间间隔,单位号码,默认为 0
*/
continuous(interval?: number, dt?: number, timeout?: number): void;
continuous(interval?: number, dt?: number, timeout?: number): this;
}
export interface IHotkeySystem {
@ -167,9 +177,14 @@ export interface IHotkeySystem {
/**
* 添加新的按键指令
* @param command 按键指令标识符
* @param name 按键指令的名称,用于显示
* @param init 指令初始化对象
*/
addCommand(command: string, init: IHotkeyCommandInit): IHotkeyCommand;
addCommand(
command: string,
name: string,
init: IHotkeyCommandInit
): IHotkeyCommand;
/**
* 删除指定按键指令

View File

@ -1,20 +1,349 @@
import { KeyCode } from '@motajs/system';
import { HotkeyEmitMode, KeyCode } from '@motajs/system';
import { IClientCore } from '@user/client-modules';
export function initializeHotkey(state: IClientCore) {
const hotkey = state.hotkey;
const system = hotkey.system;
system.excitedBy(state.rafExcitation);
//#region 通用按键
hotkey.setGroup('@system/common', [
system.addCommand('ok', {
priority: 0,
defaults: [[KeyCode.Enter], [KeyCode.Space], [KeyCode.KeyC]]
hotkey.setGroup('common', '通用按键', [
system.addCommand('@common/ok', '确认', {
defaults: [[KeyCode.Enter], [KeyCode.Space], [KeyCode.KeyC]],
priority: 0
}),
system.addCommand('exit', {
priority: 0,
defaults: [[KeyCode.Escape], [KeyCode.KeyX]]
system.addCommand('@common/exit', '退出/返回上一级', {
defaults: [[KeyCode.Escape], [KeyCode.KeyX]],
priority: 0
}),
system.addCommand('@common/select-up', '上移选择光标', {
defaults: [[KeyCode.UpArrow]],
priority: 0
}),
system.addCommand('@common/select-down', '下移选择光标', {
defaults: [[KeyCode.DownArrow]],
priority: 0
})
]);
//#endregion
//#region 游戏按键
hotkey.setGroup('game', '游戏按键', [
system.addCommand('@game/up', '向上移动', {
defaults: [[KeyCode.UpArrow]],
mode: HotkeyEmitMode.Continuous
}),
system.addCommand('@game/right', '向右移动', {
defaults: [[KeyCode.RightArrow]],
mode: HotkeyEmitMode.Continuous
}),
system.addCommand('@game/down', '向下移动', {
defaults: [[KeyCode.DownArrow]],
mode: HotkeyEmitMode.Continuous
}),
system.addCommand('@game/left', '向左移动', {
defaults: [[KeyCode.LeftArrow]],
mode: HotkeyEmitMode.Continuous
}),
system.addCommand('@game/turn', '勇士转向', {
defaults: [[KeyCode.KeyZ]]
}),
system.addCommand('@game/undo', '回退', {
defaults: [[KeyCode.KeyA]]
}),
system.addCommand('@game/redo', '恢复', {
defaults: [[KeyCode.KeyW]]
}),
system.addCommand('@game/get-next', '轻按', {
defaults: [[KeyCode.Space], [KeyCode.Digit7]]
}),
system.addCommand('@game/quick-equip', '切换/保存套装', {
defaults: [
[KeyCode.Alt, KeyCode.Digit1],
[KeyCode.Alt, KeyCode.Digit2],
[KeyCode.Alt, KeyCode.Digit3],
[KeyCode.Alt, KeyCode.Digit4],
[KeyCode.Alt, KeyCode.Digit5],
[KeyCode.Alt, KeyCode.Digit6],
[KeyCode.Alt, KeyCode.Digit7],
[KeyCode.Alt, KeyCode.Digit8],
[KeyCode.Alt, KeyCode.Digit9],
[KeyCode.Alt, KeyCode.Digit0]
]
})
]);
//#endregion
//#region UI 界面
hotkey.setGroup('ui', 'UI 界面', [
system.addCommand('@ui/book', '怪物手册', {
defaults: [[KeyCode.KeyX]]
}),
system.addCommand('@ui/save', '存档界面', {
defaults: [[KeyCode.KeyS]]
}),
system.addCommand('@ui/load', '读档界面', {
defaults: [[KeyCode.KeyD]]
}),
system.addCommand('@ui/fly', '楼层传送', {
defaults: [[KeyCode.KeyG]]
}),
system.addCommand('@ui/menu', '游戏菜单', {
defaults: [[KeyCode.Escape]]
}),
system.addCommand('@ui/view-map', '浏览地图', {
defaults: [[KeyCode.PageDown], [KeyCode.PageUp]]
}),
system.addCommand('@ui/toolbox', '道具界面', {
defaults: [[KeyCode.KeyT]]
}),
system.addCommand('@ui/command', '装备界面', {
defaults: [[KeyCode.KeyQ]]
}),
system.addCommand('@ui/replay', '回放菜单', {
defaults: [[KeyCode.KeyR]]
}),
system.addCommand('@ui/shop', '快捷商店', {
defaults: [[KeyCode.KeyV]]
}),
system.addCommand('@ui/statistics', '数据统计', {
defaults: [[KeyCode.Ctrl, KeyCode.KeyB]]
})
]);
//#endregion
//#region 系统按键
hotkey.setGroup('system', '系统按键', [
system.addCommand('@system/restart', '回到开始界面', {
defaults: [[KeyCode.KeyN]]
}),
system.addCommand('@system/comment', '评论区', {
defaults: [[KeyCode.KeyP]]
}),
system.addCommand('@system/debug', '调试模式', {
defaults: [[KeyCode.F8]]
})
]);
//#endregion
//#region 开始界面
hotkey.setGroup('@ui/start', '开始界面', [
system.addCommand('@ui/start-up', '上移光标', {
defaults: [[KeyCode.UpArrow]],
mode: HotkeyEmitMode.Once
}),
system.addCommand('@ui/start-down', '下移光标', {
defaults: [[KeyCode.DownArrow]],
mode: HotkeyEmitMode.Once
})
]);
//#endregion
//#region 怪物手册
hotkey.setGroup('@ui/book', '怪物手册', [
system.addCommand('@ui/book-up', '上移光标', {
defaults: [[KeyCode.UpArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/book-down', '下移光标', {
defaults: [[KeyCode.DownArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/book-page-up', '上移五个怪物', {
defaults: [[KeyCode.LeftArrow], [KeyCode.PageUp]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/book-page-down', '下移五个怪物', {
defaults: [[KeyCode.RightArrow], [KeyCode.PageDown]],
mode: HotkeyEmitMode.Continuous,
interval: 500
})
]);
//#endregion
//#region 常规楼层传送
hotkey.setGroup('@ui/fly', '常规楼层传送', [
system.addCommand('@ui/fly-prev', '上一张地图', {
defaults: [[KeyCode.DownArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/fly-next', '下一张地图', {
defaults: [[KeyCode.UpArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/fly-prev-10', '前十张地图', {
defaults: [[KeyCode.LeftArrow], [KeyCode.PageDown]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/fly-next-10', '后十张地图', {
defaults: [[KeyCode.RightArrow], [KeyCode.PageUp]],
mode: HotkeyEmitMode.Continuous,
interval: 500
})
]);
//#endregion
//#region 平面楼层传送
hotkey.setGroup('@ui/plane-fly', '平面楼层传送', [
system.addCommand('@ui/plane-fly-up', '上移地图', {
defaults: [[KeyCode.UpArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-right', '右移地图', {
defaults: [[KeyCode.RightArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-down', '下移地图', {
defaults: [[KeyCode.DownArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-left', '左移地图', {
defaults: [[KeyCode.LeftArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-prev', '上一张地图', {
defaults: [[KeyCode.PageDown]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-next', '下一张地图', {
defaults: [[KeyCode.PageUp]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-prev-10', '前十张地图', {
defaults: [
[KeyCode.Ctrl, KeyCode.PageDown],
[KeyCode.Ctrl, KeyCode.LeftArrow]
],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/plane-fly-next-10', '后十张地图', {
defaults: [
[KeyCode.Ctrl, KeyCode.PageUp],
[KeyCode.Ctrl, KeyCode.RightArrow]
],
mode: HotkeyEmitMode.Continuous,
interval: 500
})
]);
//#endregion
//#region 存档界面
hotkey.setGroup('@ui/save', '存档界面', [
system.addCommand('@ui/save-up', '选择框向上', {
defaults: [[KeyCode.UpArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-up', '选择框向右', {
defaults: [[KeyCode.RightArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-up', '选择框向下', {
defaults: [[KeyCode.DownArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-up', '选择框向左', {
defaults: [[KeyCode.LeftArrow]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-page-prev', '向前翻页', {
defaults: [[KeyCode.PageUp]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-page-next', '向后翻页', {
defaults: [[KeyCode.PageDown]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-page-prev', '向前十页', {
defaults: [[KeyCode.Ctrl, KeyCode.PageUp]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/save-page-next', '向后十页', {
defaults: [[KeyCode.Ctrl, KeyCode.PageDown]],
mode: HotkeyEmitMode.Continuous,
interval: 500
})
]);
//#endregion
//#region 浏览地图
hotkey.setGroup('@ui/view-map', '浏览地图', [
system.addCommand('@ui/view-map-prev', '上一张地图', {
defaults: [[KeyCode.DownArrow], [KeyCode.PageDown]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/view-map-next', '下一张地图', {
defaults: [[KeyCode.UpArrow], [KeyCode.PageUp]],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/view-map-prev-10', '前十张地图', {
defaults: [
[KeyCode.Ctrl, KeyCode.DownArrow],
[KeyCode.Ctrl, KeyCode.PageDown]
],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/view-map-next-10', '后十张地图', {
defaults: [
[KeyCode.Ctrl, KeyCode.UpArrow],
[KeyCode.Ctrl, KeyCode.PageUp]
],
mode: HotkeyEmitMode.Continuous,
interval: 500
}),
system.addCommand('@ui/view-map-book', '怪物手册', {
defaults: [[KeyCode.KeyX]],
priority: 10
}),
system.addCommand('@ui/view-map-fly', '传送至', {
defaults: [[KeyCode.KeyG]],
priority: 10
}),
system.addCommand('@ui/view-map-reset', '重置视角', {
defaults: [[KeyCode.KeyR]],
priority: 10
})
]);