refactor: 按键系统

This commit is contained in:
unanmed 2026-10-22 22:25:59 +08:00
parent d5b2a820d8
commit 564f53d82a
15 changed files with 569 additions and 419 deletions

View File

@ -193,6 +193,9 @@
- **D-69(纯内部实现章节,长期规范):** 讲解**纯内部实现**的章节,开头必须写明「此章节讲解一下 XXX 的内部实现」;这类章节应放在文档**靠后**位置,文档前面以「如何使用」为主。适用于后续所有文档写作。
- **D-70(代码块完整类上下文,长期规范):** 代码块必须包裹**完整的类上下文**(尤其是类的方法),确保语法高亮正确;不要单独展示脱离类的方法体。适用于后续所有文档写作。
- **D-71(高级 I 待补内容):** 高级 I 还有未讲内容,待后续补写:**移动 Mover**、**FaceHandler 系列接口**。当前先完成高级 II,之后再补。
- **D-72(注释优先,长期规范):** 当引擎源码的**注释与实现不一致**时,文档**一律以方法注释为准**,并在对话中**向用户报告**该不一致。不得按实现去改写注释的语义。适用于后续所有文档写作。
- **D-73(假定编辑器已完成,长期规范):** 文档**假定编辑器已经完成**。涉及编辑器 / `.jsonc` 内容配置的地方,只写「需要编辑器」的占位标识;**不要讲「编辑器未就位时改文件、就位后用编辑器」**这类过渡做法。所有 `.jsonc` 均由编辑器负责。适用于后续所有文档写作。
- **D-74(章节锚点链接,长期规范):** 引用其他文档的**具体章节**时,使用 `./xxx.md#标题` 直接定位到该标题(如 `./static-vs-dynamic-tiles.md#双向转换`)。适用于后续所有文档写作。**待办:** 高级 II 完成后,回头检查高级 I 与中级教程是否存在同类未定位的链接。
</decisions>

View File

@ -0,0 +1,66 @@
import {
HotkeySystem,
IHotkeyCommand,
IHotkeySystem,
KeyCode
} from '@motajs/system';
import { IClientHotkey, IClientHotkeySave } from './types';
import { logger } from '@motajs/common';
export class ClientHotkey implements IClientHotkey {
system: IHotkeySystem = new HotkeySystem();
/** 按键分组 */
private readonly groups: Map<string, IHotkeyCommand[]> = new Map();
setGroup(group: string, commands: IHotkeyCommand[]): void {
if (commands.length > 0) {
if (this.groups.has(group)) {
logger.warn(208, group);
}
this.groups.set(group, commands);
} else {
this.groups.delete(group);
}
}
getGroup(group: string): IHotkeyCommand[] {
return this.groups.get(group) ?? [];
}
getUngrouped(): Iterable<IHotkeyCommand> {
const grouped = new Set([...this.groups.values()].flat());
return new Set([...this.system.iterateCommands()]).difference(grouped);
}
saveState(): IClientHotkeySave {
const keys: Map<string, (readonly KeyCode[])[]> = new Map();
for (const command of this.system.iterateCommands()) {
const saveKeys: (readonly KeyCode[])[] = Array.from(
{ length: command.length },
() => []
);
for (const [index, key] of command.iterateKeyCombinations()) {
saveKeys[index] = key;
}
keys.set(command.command, saveKeys);
}
return {
keys
};
}
loadState(state: IClientHotkeySave): void {
for (const [id, keys] of state.keys) {
const command = this.system.getCommand(id);
if (!command) {
logger.warn(209, id);
return;
}
command.setKeys(keys.length);
keys.forEach((key, index) => {
command.setKeyCombination(index, key);
});
}
}
}

View File

@ -0,0 +1,2 @@
export * from './hotkey';
export * from './types';

View File

@ -0,0 +1,30 @@
import { IHotkeyCommand, IHotkeySystem, KeyCode } from '@motajs/system';
import { ISaveableContent } from '@user/data-common';
export interface IClientHotkeySave {
/** 按键指令列表 */
readonly keys: ReadonlyMap<string, readonly (readonly KeyCode[])[]>;
}
export interface IClientHotkey extends ISaveableContent<IClientHotkeySave> {
/** 按键系统对象 */
readonly system: IHotkeySystem;
/**
* 设置指定分组的按键指令列表,若不存在分组则创建,若传入空数组则删除
* @param group 按键分组名
* @param commands 分组中包含的按键指令名
*/
setGroup(group: string, commands: IHotkeyCommand[]): void;
/**
* 获取指定按键分组中包含的按键,如果不存在则返回空数组
* @param group 按键分组名
*/
getGroup(group: string): IHotkeyCommand[];
/**
* 获取未分组的按键列表
*/
getUngrouped(): Iterable<IHotkeyCommand>;
}

View File

@ -1,5 +1,6 @@
export * from './components';
export * from './elements';
export * from './hotkey';
export * from './layout';
export * from './map';
export * from './material';

View File

@ -11,6 +11,7 @@ import { ICoreState } from '@user/data-state';
import { ISaveSystem } from './save';
import { ITextureManager } from './material';
import { IMapRenderer, IMapExtensionManager } from './map';
import { IClientHotkey } from './hotkey';
export interface IGameUIPropsBase<
T extends UIComponent = UIComponent
@ -47,6 +48,8 @@ export interface IClientBase extends ICoreState {
readonly sceneController: IUIController<IGameUIPropsBase>;
/** 主界面的 UI 控制器,管理游戏主界面的 UI 显示 */
readonly mainUIController: IUIController<IGameUIPropsBase>;
/** 渲染端主按键系统 */
readonly hotkey: IClientHotkey;
}
export interface IClientBaseExtended {

View File

@ -25,7 +25,9 @@ import {
TextureManager,
ISaveSystem,
SaveSystem,
IGameUIPropsBase
IGameUIPropsBase,
ClientHotkey,
IClientHotkey
} from '@user/client-base';
import {
IMapRenderer,
@ -59,6 +61,7 @@ export class ClientCore extends CoreState implements IClientCore {
readonly mainMapExtension: IMapExtensionManager;
readonly sceneController: IUIController<IGameUIPropsBase>;
readonly mainUIController: IUIController<IGameUIPropsBase>;
readonly hotkey: IClientHotkey;
// Layer 5 渲染顶层 - 目前没东西
@ -79,6 +82,7 @@ export class ClientCore extends CoreState implements IClientCore {
config.tilesetReserve,
config.tilesetUnit
);
this.hotkey = new ClientHotkey();
this.rafExcitation = new RafExcitation();
this.excitationDivider = new ExcitationDivider<number>();

View File

@ -293,9 +293,13 @@
"200": "Cannot buy or sell item since target item '$1' does not exist.",
"201": "Cannot set hotkey condition or implement command since no scope is active.",
"202": "Cannot implement nonexistent hotkey command '$1'.",
"203": "Hotkey command '$1' has an invalid KeyUp combination: at most one non-auxiliary key is allowed.",
"203": "Hotkey command '$1' has an invalid KeyUp combination: at most one non-auxiliary key is allowed. This command will be never emitted by invalid command.",
"204": "Duplicate hotkey command '$1', the existing command will be used.",
"205": "Cannot leave nonexistent hotkey scope.",
"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.",
"1001": "Event(setBlock): Unknown tile '$1' when setting block."
}
}

View File

@ -20,3 +20,27 @@ export function cumsum(seq: Iterable<number>): number[] {
}
return result;
}
/**
* 判断一个数组的所有项,符合的项组成 `match`,不符合的组成 `unmatch`,返回 `[match, unmatch]`
* @param array 要判断的数组
* @param filter 判断函数
* @example matchArray([1, 2, 3, 4], v => v < 2); // [[1], [2, 3, 4]]
*/
export function matchArray<T>(
array: readonly T[],
filter: (v: T, i: number, a: readonly T[]) => boolean
): [match: T[], unmatch: T[]] {
const match: T[] = [];
const unmatch: T[] = [];
array.forEach((v, i, a) => {
if (filter(v, i, a)) {
match.push(v);
} else {
unmatch.push(v);
}
});
return [match, unmatch];
}

View File

@ -24,20 +24,20 @@ export class HotkeyCommand implements IHotkeyCommand {
dt: number;
length: number;
/** 指令标识符,用于告警信息 */
private readonly command: string;
/** 按键组合列表,长度与 {@link length} 保持一致 */
private combinations: KeyCode[][];
private keys: KeyCode[][] = [];
constructor(command: string, init: IHotkeyCommandInit) {
constructor(
readonly command: string,
init: IHotkeyCommandInit
) {
this.command = command;
this.priority = init.priority;
this.block = init.block;
this.block = init.block ?? false;
this.mode = init.mode ?? HotkeyEmitMode.KeyUp;
this.timeout = init.timeout ?? 0;
this.interval = init.interval ?? 0;
this.dt = init.dt ?? 0;
this.combinations = [];
this.length = 0;
this.setKeys(init.defaults.length);
init.defaults.forEach((keys, index) =>
@ -46,69 +46,78 @@ export class HotkeyCommand implements IHotkeyCommand {
}
setKeys(count: number): void {
if (!Number.isInteger(count) || count < 0) return;
while (this.combinations.length < count) {
this.combinations.push([]);
if (!Number.isInteger(count)) {
logger.warn(207, 'float');
return;
}
if (this.combinations.length > count) {
this.combinations.length = count;
if (count <= 0) {
logger.warn(207, 'negative integer');
return;
}
this.length = this.combinations.length;
if (count > this.keys.length) {
while (count > this.keys.length) {
this.keys.push([]);
}
} else if (count < this.keys.length) {
while (count < this.keys.length) {
this.keys.pop();
}
}
this.length = this.keys.length;
}
setKeyCombination(index: number, keys: KeyCode[]): void {
if (
!Number.isInteger(index) ||
index < 0 ||
index >= this.combinations.length
) {
if (!Number.isInteger(index)) {
logger.warn(206, 'float');
return;
}
const copy = keys.slice();
this.combinations[index] = copy;
if (index < 0) {
logger.warn(206, 'negetive integer');
return;
}
if (index >= this.keys.length) {
logger.warn(206, 'integer but exceeds combination count');
return;
}
this.keys[index] = keys.slice();
if (this.mode === HotkeyEmitMode.KeyUp) {
const regular = copy.filter(v => !isAuxiliaryKey(v));
const regular = keys.filter(v => !isAuxiliaryKey(v));
if (regular.length >= 2) {
logger.warn(203, this.command);
}
}
}
getKeyCombination(index: number): KeyCode[] {
const keys = this.combinations[index];
return keys ? keys.slice() : [];
getKeyCombination(index: number): readonly KeyCode[] {
return this.keys[index]?.slice() ?? [];
}
*iterateKeyCombinations(): Iterable<[index: number, key: KeyCode[]]> {
for (let i = 0; i < this.combinations.length; i++) {
yield [i, this.combinations[i].slice()];
*iterateKeyCombinations(): Iterable<
[index: number, key: readonly KeyCode[]]
> {
for (let i = 0; i < this.keys.length; i++) {
yield [i, this.keys[i].slice()];
}
}
keyUp(timeout?: number): void {
keyUp(timeout: number = 0): void {
this.mode = HotkeyEmitMode.KeyUp;
if (timeout !== undefined) this.timeout = timeout;
const invalid = this.combinations.some(
keys => keys.filter(v => !isAuxiliaryKey(v)).length >= 2
);
if (invalid) logger.warn(203, this.command);
this.timeout = timeout;
}
once(timeout?: number): void {
once(timeout: number = 0): void {
this.mode = HotkeyEmitMode.Once;
if (timeout !== undefined) this.timeout = timeout;
this.timeout = timeout;
}
continuous(interval?: number, timeout?: number): void {
continuous(
interval: number = 0,
dt: number = 0,
timeout: number = 0
): void {
this.mode = HotkeyEmitMode.Continuous;
if (interval !== undefined) this.interval = interval;
if (timeout !== undefined) this.timeout = timeout;
}
throttle(dt: number, interval?: number, timeout?: number): void {
this.mode = HotkeyEmitMode.Throttle;
this.interval = interval;
this.dt = dt;
if (interval !== undefined) this.interval = interval;
if (timeout !== undefined) this.timeout = timeout;
this.timeout = timeout;
}
}

View File

@ -1,10 +1,9 @@
import { IExcitable, IExcitableController, IExcitation } from '@motajs/animate';
import { logger } from '@motajs/common';
import { IExcitable, IExcitation } from '@motajs/animate';
import { logger, matchArray } from '@motajs/common';
import { HotkeyCommand, isAuxiliaryKey } from './command';
import { EVENT_KEY_CODE_MAP, KeyCode } from './keyCodes';
import {
HotkeyEmitMode,
HotkeyEmitType,
IHotkeyCommand,
IHotkeyCommandInit,
IHotkeyEmitContext,
@ -12,99 +11,95 @@ import {
IHotkeySystem
} from './type';
type HotkeyHandler = (ctx: IHotkeyEmitInfo) => void;
type HotkeyListener = (event: KeyboardEvent) => void;
// TODO: 优先级阻断
interface IHotkeyScope {
/** 作用域唯一标识符 */
symbol: symbol;
readonly symbol: symbol;
/** 作用域内已实现的按键指令 */
readonly implements: Map<IHotkeyCommand, (info: IHotkeyEmitInfo) => void>;
/** 作用域条件,为 `null` 时视为始终满足 */
condition: (() => boolean) | null;
/** 作用域内已实现的按键指令 */
handlers: Map<string, HotkeyHandler>;
}
interface IHotkeyMatch {
/** 指令标识符 */
id: string;
/** 指令对象 */
command: IHotkeyCommand;
/** 命中的按键组合索引 */
index: number;
/** 命中的按键组合 */
keys: KeyCode[];
/** 指令实现 */
handler: HotkeyHandler;
/** 关联的待触发项 */
entry?: IHotkeyPending;
}
interface IHotkeyPending {
/** 指令标识符 */
id: string;
/** 指令对象 */
command: IHotkeyCommand;
/** 命中的按键组合索引 */
index: number;
/** 命中的按键组合 */
keys: KeyCode[];
/** 指令实现 */
handler: HotkeyHandler;
/** 触发方式 */
type: HotkeyEmitType;
/** 触发此指令的按键 */
key: KeyCode;
/** 入队时的作用域 */
scope: symbol;
/** 入队时的触发方式快照 */
mode: HotkeyEmitMode;
/** 入队时的首次触发延迟快照 */
timeout: number;
/** 入队时的首次与第二次触发最小间隔快照 */
interval: number;
/** 入队时的节流间隔快照 */
dt: number;
/** 入队时刻 */
time: number;
/** 上一次真正触发的时刻 */
last: number;
/** 已触发的次数 */
fired: number;
interface IHotkeyCommandPending {
/** 按键指令对象 */
readonly command: IHotkeyCommand;
/** 按键组合被按下的时刻 */
start: number;
/** 按键组合的索引 */
keyIndex: number;
/** 触发此指令的按键组合 */
keys: readonly KeyCode[];
/** 按键触发上下文 */
context: IHotkeyEmitContext;
/** 当前已经触发了几次 */
emitted: number;
/** 上一次的触发时刻 */
lastEmit: number;
}
export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
listening: HTMLElement | null = null;
holding: KeyCode[] = [];
/** 指令注册表 */
private commands: Map<string, IHotkeyCommand> = new Map();
/** 作用域栈 */
private scopes: IHotkeyScope[] = [];
/** 当前按下的按键,按按下顺序排列 */
private holding: KeyCode[] = [];
/** 待触发项 */
private pending: IHotkeyPending[] = [];
/** 最近一次激励负载 */
private now: number = 0;
/** 当前激励源控制器 */
private controller: IExcitableController<number> | null = null;
/** 键盘按下处理器 */
private keyDownListener: HotkeyListener = event => {
if (event.repeat) return;
const key = this.resolveKeyCode(event.keyCode);
if (key === null) return;
if (this.down(key)) event.preventDefault();
};
/** 键盘抬起处理器 */
private keyUpListener: HotkeyListener = event => {
const key = this.resolveKeyCode(event.keyCode);
if (key === null) return;
if (this.up(key)) event.preventDefault();
};
//#region 基础
/** 当前使用的激励源 */
private excitation: IExcitation<number> | null = null;
/** 获取当前栈顶作用域 */
/** 当前正在按下的按键组成的集合 */
private holdingSet: Set<KeyCode> = new Set();
/** 如果按键指令都是按下时触发,那么当前的按键组合下,有可能触发哪些按键 */
private holdingCmds: Map<IHotkeyCommand, IHotkeyCommandPending> = new Map();
/** 需要等待 `timeout` 进行一次触发的指令集合 */
private waitTimeout: Set<IHotkeyCommandPending> = new Set();
/** 键盘按下监听器 */
private keyDownListener: (ev: KeyboardEvent) => void;
/** 键盘抬起监听器 */
private keyUpListener: (ev: KeyboardEvent) => void;
constructor() {
const keyDown = (event: KeyboardEvent) => {
const key = this.resolveKeyCode(event.keyCode);
if (this.holding.includes(key)) return;
if (key === KeyCode.Unknown) return;
const emitted = this.down(key);
if (emitted) event.preventDefault();
};
const keyUp = (event: KeyboardEvent) => {
const key = this.resolveKeyCode(event.keyCode);
if (key === KeyCode.Unknown) return;
const emitted = this.up(key);
if (emitted) event.preventDefault();
};
this.keyDownListener = keyDown;
this.keyUpListener = keyUp;
}
/**
* 将浏览器事件的按键码映射为按键枚举,部分浏览器的 Meta 键需要本地映射
* @param eventKeyCode 浏览器事件的按键码
*/
private resolveKeyCode(eventKeyCode: number): KeyCode {
if (
eventKeyCode === 91 ||
eventKeyCode === 92 ||
eventKeyCode === 224
) {
return KeyCode.Meta;
}
const key = EVENT_KEY_CODE_MAP[eventKeyCode];
return key ?? KeyCode.Unknown;
}
/**
* 获取当前栈顶作用域
*/
private getScope(): IHotkeyScope | null {
return this.scopes.at(-1) ?? null;
}
@ -114,253 +109,73 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
* @param type 触发方式
* @param key 触发此指令的按键
*/
private createContext(
type: HotkeyEmitType,
key: KeyCode
): IHotkeyEmitContext {
const holding = this.holding.slice();
private createContext(): IHotkeyEmitContext {
return {
type,
key,
holding,
ctrl: holding.includes(KeyCode.Ctrl),
shift: holding.includes(KeyCode.Shift),
alt: holding.includes(KeyCode.Alt),
meta: holding.includes(KeyCode.Meta)
holding: this.holding,
ctrl: this.holdingSet.has(KeyCode.Ctrl),
shift: this.holdingSet.has(KeyCode.Shift),
alt: this.holdingSet.has(KeyCode.Alt),
meta: this.holdingSet.has(KeyCode.Meta)
};
}
//#endregion
//#region 组合匹配
/**
* 判断按键组合是否与按键抬起事件匹配
* @param keys 按键组合
* @param keys 当前的按键组合
* @param key 抬起的按键
*/
private matchKeyUp(keys: KeyCode[], key: KeyCode): boolean {
private matchKeyUp(keys: readonly KeyCode[], key: KeyCode): boolean {
if (keys.length === 0) return false;
const regular = keys.filter(v => !isAuxiliaryKey(v));
if (regular.length > 1) return false;
if (regular.length === 0) {
return keys.length === 1 && keys[0] === key;
// 抬起按键仅允许一个非辅助按键
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 (regular[0] !== key) return false;
return keys
.filter(v => isAuxiliaryKey(v))
.every(v => this.holding.includes(v));
// 否则需要判断触发的按键
if (main[0] !== key) return false;
return auxiliary.every(v => this.holdingSet.has(v));
}
/**
* 判断按键组合是否被当前按下的按键所满足
* 判断一个按键组合当前是否满足
* @param keys 按键组合
*/
private matchKeyDown(keys: KeyCode[]): boolean {
if (keys.length === 0) return false;
return keys.every(v => this.holding.includes(v));
private matchKeys(keys: readonly KeyCode[]) {
return keys.every(v => this.holdingSet.has(v));
}
//#endregion
//#region 待触发
/**
* 判断一个待触发项是否已经到期
* @param item 待触发项
* @param payload 当前激励负载
*/
private isPendingDue(item: IHotkeyPending, payload: number): boolean {
if (item.fired === 0) {
return payload - item.time >= item.timeout;
}
if (item.fired === 1) {
return payload - item.last >= item.interval;
}
if (item.mode === HotkeyEmitMode.Throttle) {
return payload - item.last >= item.dt;
}
return true;
}
/** 触发所有已经到期的待触发项 */
private flushPending(): void {
this.dropPending();
const due: IHotkeyPending[] = [];
const rest: IHotkeyPending[] = [];
for (const item of this.pending) {
if (this.isPendingDue(item, this.now)) {
due.push(item);
} else {
rest.push(item);
}
}
this.pending = rest;
due.sort((a, b) => b.command.priority - a.command.priority);
let blocked = -Infinity;
for (const item of due) {
const priority = item.command.priority;
if (priority < blocked) continue;
item.handler({
command: item.id,
index: item.index,
keys: item.keys.slice(),
context: this.createContext(item.type, item.key)
});
item.last = this.now;
item.fired += 1;
if (item.command.block) blocked = priority;
if (
item.mode === HotkeyEmitMode.Once ||
item.mode === HotkeyEmitMode.KeyUp
) {
continue;
}
this.pending.push(item);
}
}
/** 丢弃已经失效的待触发项 */
private dropPending(): void {
const scope = this.getScope();
const symbol = scope ? scope.symbol : null;
this.pending = this.pending.filter(item => {
if (item.scope !== symbol) return false;
if (item.command.mode !== item.mode) return false;
if (
item.mode !== HotkeyEmitMode.KeyUp &&
!this.matchKeyDown(item.keys)
) {
return false;
}
return true;
});
}
//#endregion
//#region 派发
/**
* 执行一批已经按优先级降序排列的匹配项
* @param matches 匹配项
* @param type 触发方式
* @param key 触发此指令的按键
*/
private runMatches(
matches: IHotkeyMatch[],
type: HotkeyEmitType,
key: KeyCode
): boolean {
const scope = this.getScope();
if (!scope) return false;
let blocked = -Infinity;
let triggered = false;
for (const item of matches) {
const priority = item.command.priority;
if (priority < blocked) break;
const mode = item.command.mode;
const timeout = item.command.timeout;
const immediate = timeout <= 0;
if (immediate) {
item.handler({
command: item.id,
index: item.index,
keys: item.keys,
context: this.createContext(type, key)
});
}
triggered = true;
const continuous =
mode === HotkeyEmitMode.Continuous ||
mode === HotkeyEmitMode.Throttle;
if (!immediate || continuous) {
this.pending.push({
id: item.id,
command: item.command,
index: item.index,
keys: item.keys.slice(),
handler: item.handler,
type,
key,
scope: scope.symbol,
mode,
timeout,
interval: item.command.interval,
dt: item.command.dt,
time: this.now,
last: immediate ? this.now : 0,
fired: immediate ? 1 : 0
});
}
if (item.command.block) blocked = priority;
}
return triggered;
}
/**
* 派发一次按键事件
* @param type 触发方式
* @param key 触发此指令的按键
*/
private dispatch(type: HotkeyEmitType, key: KeyCode): boolean {
const scope = this.getScope();
if (!scope) return false;
if (scope.condition !== null && !scope.condition()) return false;
const matches: IHotkeyMatch[] = [];
for (const [id, handler] of scope.handlers) {
const command = this.commands.get(id);
if (!command) continue;
const keyDown = command.mode !== HotkeyEmitMode.KeyUp;
if (keyDown !== (type === HotkeyEmitType.KeyDown)) continue;
let index = -1;
let keys: KeyCode[] = [];
for (const [i, combination] of command.iterateKeyCombinations()) {
const hit =
type === HotkeyEmitType.KeyUp
? this.matchKeyUp(combination, key)
: this.matchKeyDown(combination);
if (hit) {
index = i;
keys = combination;
break;
}
}
if (index < 0) continue;
matches.push({ id, command, index, keys, handler });
}
matches.sort((a, b) => b.command.priority - a.command.priority);
return this.runMatches(matches, type, key);
}
//#endregion
//#region 作用域
enterScope(): symbol {
const symbol = Symbol();
this.scopes.push({ symbol, condition: null, handlers: new Map() });
this.dropPending();
this.scopes.push({ symbol, condition: null, implements: new Map() });
this.holdingCmds.clear();
this.waitTimeout.clear();
return symbol;
}
leaveScope(sym?: symbol): void {
if (sym === undefined) {
if (!sym) {
if (this.scopes.length === 0) {
logger.warn(205);
return;
}
this.scopes.pop();
this.dropPending();
return;
this.holdingCmds.clear();
this.waitTimeout.clear();
}
const index = this.scopes.findIndex(v => v.symbol === sym);
if (index < 0) {
logger.warn(205);
return;
}
this.scopes.length = index + 1;
this.dropPending();
this.scopes.splice(index + 1);
this.holdingCmds.clear();
this.waitTimeout.clear();
}
when(func: () => boolean): this {
@ -373,17 +188,18 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
return this;
}
implement(command: string, func: HotkeyHandler): this {
implement(command: string, func: (ctx: IHotkeyEmitInfo) => void): this {
const scope = this.getScope();
if (!scope) {
logger.warn(201);
return this;
}
if (!this.commands.has(command)) {
const obj = this.commands.get(command);
if (!obj) {
logger.warn(202, command);
return this;
}
scope.handlers.set(command, func);
scope.implements.set(obj, func);
return this;
}
@ -407,80 +223,235 @@ export class HotkeySystem implements IHotkeySystem, IExcitable<number> {
}
deleteCommand(command: string): void {
if (!this.commands.delete(command)) return;
this.scopes.forEach(scope => scope.handlers.delete(command));
this.pending = this.pending.filter(item => item.id !== command);
const obj = this.commands.get(command);
if (!obj) return;
this.commands.delete(command);
this.scopes.forEach(scope => scope.implements.delete(obj));
this.holdingCmds.delete(obj);
const toDeleteWait = new Set<IHotkeyCommandPending>();
this.waitTimeout.forEach(v => {
if (v.command === obj) {
toDeleteWait.add(v);
}
});
toDeleteWait.forEach(v => {
this.waitTimeout.delete(v);
});
}
*iterateCommands(): Iterable<IHotkeyCommand> {
yield* this.commands.values();
iterateCommands(): Iterable<IHotkeyCommand> {
return this.commands.values();
}
//#endregion
//#region 按下与抬起
/**
* 触发指定按键指令
* @param command 按键指令对象
* @param pending 按键指令的 pending 信息
*/
private emit(pending: IHotkeyCommandPending) {
const command = pending.command;
const scope = this.getScope();
if (!scope) return;
const impl = scope.implements.get(command);
if (!impl) return;
const info: IHotkeyEmitInfo = {
mode: command.mode,
command,
index: pending.keyIndex,
keys: pending.keys,
context: pending.context
};
impl(info);
pending.emitted++;
pending.lastEmit = this.excitation?.payload() ?? 0;
}
/**
* 获取首个能够触发按键指令的按键组合
* @param command 按键指令
*/
private findMatchKeys(
command: IHotkeyCommand
): [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];
}
return [];
}
/**
* 添加可能触发的按键指令
* @param command 按键指令对象
* @param index 触发的按键索引
* @param keys 按键组合
*/
private addPending(
command: IHotkeyCommand,
index: number,
keys: readonly KeyCode[]
) {
const pending: IHotkeyCommandPending = {
command,
start: this.excitation?.payload() ?? 0,
keyIndex: index,
keys,
context: this.createContext(),
lastEmit: -Infinity,
emitted: 0
};
this.holdingCmds.set(command, pending);
if (command.timeout <= 0 && command.mode !== HotkeyEmitMode.KeyUp) {
// 如果没有 timeout,那么需要立即触发一次
this.emit(pending);
}
}
down(key: KeyCode): boolean {
if (this.holding.includes(key)) return false;
this.holding.push(key);
return this.dispatch(HotkeyEmitType.KeyDown, key);
if (this.holdingSet.has(key)) {
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
} else {
this.holding.push(key);
this.holdingSet.add(key);
}
for (const command of this.commands.values()) {
const match = this.findMatchKeys(command);
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 {
// 如果之前没有这个按键指令,那么加入执行列表
this.addPending(command, index, keys);
continue;
}
}
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
}
up(key: KeyCode): boolean {
if (!this.holdingSet.has(key)) return false;
this.holdingSet.delete(key);
const index = this.holding.indexOf(key);
if (index < 0) return false;
this.holding.splice(index, 1);
this.dropPending();
return this.dispatch(HotkeyEmitType.KeyUp, key);
// 需要快照
for (const [command, pending] of new Map(this.holdingCmds)) {
// 对于 KeyUp,如果有可以触发的,那么按规则触发
if (command.mode === HotkeyEmitMode.KeyUp) {
const emit = this.matchKeyUp(pending.keys, key);
if (emit) {
this.emit(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;
}
}
}
}
// 如果有按键可能触发,那么返回 true 来 preventDefault
return this.holdingCmds.size > 0 || this.waitTimeout.size > 0;
}
//#endregion
//#region 激励
excitedBy(excitation: IExcitation<number>): void {
if (this.controller) {
this.controller.revoke();
excitedBy(excitation: IExcitation<number> | null): void {
if (this.excitation) {
this.excitation.remove(this);
}
this.excitation = excitation;
if (excitation) {
excitation.add(this);
}
this.controller = excitation.add(this);
}
excited(payload: number): void {
this.now = payload;
this.flushPending();
// 先判定 timeout,需要快照
for (const pending of new Set(this.waitTimeout)) {
const dt = payload - pending.start;
if (dt >= pending.command.timeout) {
this.emit(pending);
this.waitTimeout.delete(pending);
}
}
// 再看 holding,这个不会删,所以不需要快照
for (const [command, pending] of this.holdingCmds) {
// 抬起不会在这里触发
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 (command.mode === HotkeyEmitMode.Continuous) {
if (pending.emitted === 1) {
// 先看第二次触发
if (dt2 >= command.interval) {
this.emit(pending);
}
} else {
// 其他的要看 dt 了
if (dt2 >= command.dt) {
this.emit(pending);
}
}
}
}
}
//#endregion
//#region 监听
/**
* 将浏览器事件的按键码映射为按键枚举,部分浏览器的 Meta 键需要本地映射
* @param eventKeyCode 浏览器事件的按键码
*/
private resolveKeyCode(eventKeyCode: number): KeyCode | null {
if (
eventKeyCode === 91 ||
eventKeyCode === 92 ||
eventKeyCode === 224
) {
return KeyCode.Meta;
}
const key = EVENT_KEY_CODE_MAP[eventKeyCode];
return key ? key : null;
}
listen(target: HTMLElement | null): void {
if (this.listening === target) return;
if (this.listening) {
this.listening.removeEventListener('keydown', this.keyDownListener);
this.listening.removeEventListener('keyup', this.keyUpListener);
}
this.listening = target;
if (target) {
target.addEventListener('keydown', this.keyDownListener);
target.addEventListener('keyup', this.keyUpListener);
}
this.listening = target;
}
//#endregion

View File

@ -1,14 +1,10 @@
import { IExcitation } from '@motajs/animate';
import { KeyCode } from './keyCodes';
// TODO: 适配存档,可能需要把 ISaveableContent 挪到 @motajs/common,或让 packages-user 自己负责存档
export const enum HotkeyEmitType {
/** 由按键按下触发 */
KeyDown,
/** 由按键抬起触发 */
KeyUp
}
// 需要注意的是,所有的 “最小触发间隔” 指的都是系统能保证两次按键的触发间隔不小于此值,并不能保证等于此值,
// 更不能保证连续触发 n 次后,总体经过的时间为 n 乘以此间隔。
// 例如,如果触发了 10 次,触发间隔为 100ms,那么经过的时间一定大于等于 1000ms,绝大多数情况下都会大于 1000ms。
// 因此,不要拿这个触发间隔作为精确计时器,需要精确计时器时使用传递给系统的激励源的 `payload` 接口获取。
export const enum HotkeyEmitMode {
/** 按键抬起时触发 */
@ -16,35 +12,42 @@ export const enum HotkeyEmitMode {
/** 按下时只会触发一次 */
Once,
/** 按下后依据激励源进行触发 */
Continuous,
/** 按下后节流触发 */
Throttle
Continuous
}
export interface IHotkeyEmitContext {
/** 按键触发方式 */
readonly type: HotkeyEmitType;
/** 触发此指令的按键 */
readonly key: KeyCode;
/** 当前所有正在按下的按键,按照按下的顺序排列,注意如果是抬起事件触发的,那么这里面并不会包含触发事件的那个按键 */
readonly holding: KeyCode[];
/** 当前是否按下了 Ctrl 按键 */
/**
* 指令触发时,当前所有正在按下的按键,按照按下的顺序排列。如果是抬起事件触发的,那么这里面并不会包含触发事件的那个主要按键。
* 注意该成员的含义不是当前时刻的按下的按键,对于连续触发以及 `timeout`,依然会包含触发此指令的时刻按下的按键。
* 也就是说,它并不是当前时刻按下的按键,不要用它获取当前时刻正在按下的按键。参考时间线:
*
* ```txt
* ----------|---------------|---------->
* | timeout |
* 指令触发时刻 指令延时触发
* 记录的是这个 而不是这个
* ```
*/
readonly holding: readonly KeyCode[];
/** 指令触发时是否按下了 Ctrl 按键 */
readonly ctrl: boolean;
/** 当前是否按下了 Shift 按键 */
/** 指令触发时是否按下了 Shift 按键 */
readonly shift: boolean;
/** 当前是否按下了 Alt 按键 */
/** 指令触发时是否按下了 Alt 按键 */
readonly alt: boolean;
/** 当前是否按下了 Windows(Windows) / Command(MacOS) 按键 */
/** 指令触发时是否按下了 Windows(Windows) / Command(MacOS) 按键 */
readonly meta: boolean;
}
export interface IHotkeyEmitInfo {
/** 按键指令标识符 */
readonly command: string;
/** 按键触发方式 */
readonly mode: HotkeyEmitMode;
/** 按键指令对象 */
readonly command: IHotkeyCommand;
/** 触发此按键指令的按键组合对应索引 */
readonly index: number;
/** 触发此按键指令的按键组合 */
readonly keys: KeyCode[];
/** 触发此按键指令的按键组合。如果有两组按键组合能触发,那么仅包含触发较晚的那一个 */
readonly keys: readonly KeyCode[];
/** 按键触发上下文 */
readonly context: IHotkeyEmitContext;
}
@ -52,10 +55,10 @@ export interface IHotkeyEmitInfo {
export interface IHotkeyCommandInit {
/** 按键指令优先级 */
readonly priority: number;
/** 是否阻断优先级精确比此按键指令更低的指令的执行,同优先级的会全部执行,并不会阻断 */
readonly block: boolean;
/** 默认按键组合,可设置多组 */
readonly defaults: KeyCode[][];
/** 是否阻断优先级精确比此按键指令更低的指令的执行,同优先级的会全部执行,并不会阻断,默认不阻断 */
readonly block?: boolean;
/** 按键触发方式,默认为抬起时触发 */
readonly mode?: HotkeyEmitMode;
/** 从指定按键组合被触发,至指令首次被真正执行期间,最少经过的时间 */
@ -67,6 +70,8 @@ export interface IHotkeyCommandInit {
}
export interface IHotkeyCommand {
/** 按键指令的标识符 */
readonly command: string;
/** 按键指令优先级,越高越先执行,如果同优先级,则无法保证顺序 */
readonly priority: number;
/** 触发后,是否阻断优先级比当前优先级更低的按键的触发 */
@ -79,7 +84,7 @@ export interface IHotkeyCommand {
readonly interval: number;
/** 按下后距离第一次触发的最小时间间隔 */
readonly timeout: number;
/** Throttle 模式下,两次触发之间的最小时间间隔 */
/** 连续触发模式下,两次触发之间的最小时间间隔 */
readonly dt: number;
/**
@ -93,18 +98,20 @@ export interface IHotkeyCommand {
* @param index 按键组合索引
* @param keys 按键组合
*/
setKeyCombination(index: number, keys: KeyCode[]): void;
setKeyCombination(index: number, keys: readonly KeyCode[]): void;
/**
* 获取指定索引的按键组合,如果组合不存在返回空数组
* @param index 按键组合索引
*/
getKeyCombination(index: number): KeyCode[];
getKeyCombination(index: number): readonly KeyCode[];
/**
* 迭代所有的按键组合
*/
iterateKeyCombinations(): Iterable<[index: number, key: KeyCode[]]>;
iterateKeyCombinations(): Iterable<
[index: number, key: readonly KeyCode[]]
>;
/**
* 抬起后,在经过 `timeout` 时间后触发一次
@ -122,29 +129,23 @@ export interface IHotkeyCommand {
* 当按下后,在经过 `timeout` 时间后进行一次触发,之后在松开前,会按照激励源的激励触发
* @param interval 第一次触发距离第二次触发的最小时间间隔,设置后,按下时会立刻进行一次触发,
* 而第二次触发需要等待 `interval` 毫秒才会触发。默认为 0
* @param dt 节流间隔,默认为 0,即每次激励源的激励都会触发
* @param timeout 按下后距离首次触发的时间间隔,单位号码,默认为 0
*/
continuous(interval?: number, timeout?: number): void;
/**
* 当按下后,在经过 `timeout` 时间后进行一次触发,之后在松开前,会按照激励源的激励触发,两次触发的触发间隔不小于节流间隔
* @param dt 节流间隔
* @param interval 第一次触发距离第二次触发的最小时间间隔,设置后,按下时会立刻进行一次触发,
* 而第二次触发需要等待 `interval` 毫秒才会触发。默认为 0
* @param timeout 按下后距离首次触发的时间间隔,单位号码,默认为 0
*/
throttle(dt: number, interval?: number, timeout?: number): void;
continuous(interval?: number, dt?: number, timeout?: number): void;
}
export interface IHotkeySystem {
/** 当前正在监听的元素 */
readonly listening: HTMLElement | null;
/** 当前正在按下的按键,按照按下的顺序排序 */
readonly holding: readonly KeyCode[];
/**
* 设置当前按键系统由指定激励源所激励,用于连续触发按下后持续触发等触发场景
* @param excitation 激励源
*/
excitedBy(excitation: IExcitation<number>): void;
excitedBy(excitation: IExcitation<number> | null): void;
/**
* 进入新的按键作用域

View File

@ -6,17 +6,20 @@ import {
IClientPluginDefine
} from '@user/client-modules';
import { ICoreStateCoreConfig } from '@user/data-state';
import {
DEBUG_DIVIDER,
DEBUG_VARIATOR,
DIVIDER_DEBUG_DIVIDER,
VARIATOR_DEBUG_SPEED
} from '../constant';
import { ExcitationVariator } from '@motajs/animate';
import { SaveCompression } from '@user/data-common';
import { IClientHotkeySave } from '@user/client-base';
import {
DEBUG_VARIATOR,
VARIATOR_DEBUG_SPEED,
DEBUG_DIVIDER,
DIVIDER_DEBUG_DIVIDER
} from './constant';
export class MainClientPlugin implements IClientPluginDefine {
id: string = 'main-client-plugin';
clientDeps: readonly string[] = [];
dataDeps: readonly string[] = [];
awake(state: IClientCore): void {
// 渲染调试配置
@ -50,5 +53,12 @@ export class MainClientPlugin implements IClientPluginDefine {
// 存档初始化
state.save.init(`@game/${core.name}`);
// 按键不是跟随存档的,而是跟随游戏的,所以不进 state 的 addSaveableContent,而是单独调用 global
const hotkey =
await state.save.getGlobal<IClientHotkeySave>('@system/hotkey');
if (hotkey) {
state.hotkey.loadState(hotkey, SaveCompression.NoCompression);
}
}
}

22
src/client/misc/hotkey.ts Normal file
View File

@ -0,0 +1,22 @@
import { KeyCode } from '@motajs/system';
import { IClientCore } from '@user/client-modules';
export function initializeHotkey(state: IClientCore) {
const hotkey = state.hotkey;
const system = hotkey.system;
//#region 通用按键
hotkey.setGroup('@system/common', [
system.addCommand('ok', {
priority: 0,
defaults: [[KeyCode.Enter], [KeyCode.Space], [KeyCode.KeyC]]
}),
system.addCommand('exit', {
priority: 0,
defaults: [[KeyCode.Escape], [KeyCode.KeyX]]
})
]);
//#endregion
}

0
src/client/misc/index.ts Normal file
View File