diff --git a/.planning/phases/10-docs-restructure/10-CONTEXT.md b/.planning/phases/10-docs-restructure/10-CONTEXT.md index 1ca2036..dbeae45 100644 --- a/.planning/phases/10-docs-restructure/10-CONTEXT.md +++ b/.planning/phases/10-docs-restructure/10-CONTEXT.md @@ -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 与中级教程是否存在同类未定位的链接。 diff --git a/packages-user/client-base/src/hotkey/hotkey.ts b/packages-user/client-base/src/hotkey/hotkey.ts new file mode 100644 index 0000000..4fa343b --- /dev/null +++ b/packages-user/client-base/src/hotkey/hotkey.ts @@ -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 = 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 { + const grouped = new Set([...this.groups.values()].flat()); + return new Set([...this.system.iterateCommands()]).difference(grouped); + } + + saveState(): IClientHotkeySave { + const keys: Map = 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); + }); + } + } +} diff --git a/packages-user/client-base/src/hotkey/index.ts b/packages-user/client-base/src/hotkey/index.ts new file mode 100644 index 0000000..21fcb95 --- /dev/null +++ b/packages-user/client-base/src/hotkey/index.ts @@ -0,0 +1,2 @@ +export * from './hotkey'; +export * from './types'; diff --git a/packages-user/client-base/src/hotkey/types.ts b/packages-user/client-base/src/hotkey/types.ts new file mode 100644 index 0000000..5c78117 --- /dev/null +++ b/packages-user/client-base/src/hotkey/types.ts @@ -0,0 +1,30 @@ +import { IHotkeyCommand, IHotkeySystem, KeyCode } from '@motajs/system'; +import { ISaveableContent } from '@user/data-common'; + +export interface IClientHotkeySave { + /** 按键指令列表 */ + readonly keys: ReadonlyMap; +} + +export interface IClientHotkey extends ISaveableContent { + /** 按键系统对象 */ + readonly system: IHotkeySystem; + + /** + * 设置指定分组的按键指令列表,若不存在分组则创建,若传入空数组则删除 + * @param group 按键分组名 + * @param commands 分组中包含的按键指令名 + */ + setGroup(group: string, commands: IHotkeyCommand[]): void; + + /** + * 获取指定按键分组中包含的按键,如果不存在则返回空数组 + * @param group 按键分组名 + */ + getGroup(group: string): IHotkeyCommand[]; + + /** + * 获取未分组的按键列表 + */ + getUngrouped(): Iterable; +} diff --git a/packages-user/client-base/src/index.ts b/packages-user/client-base/src/index.ts index 24d06d0..62485c5 100644 --- a/packages-user/client-base/src/index.ts +++ b/packages-user/client-base/src/index.ts @@ -1,5 +1,6 @@ export * from './components'; export * from './elements'; +export * from './hotkey'; export * from './layout'; export * from './map'; export * from './material'; diff --git a/packages-user/client-base/src/types.ts b/packages-user/client-base/src/types.ts index 168abb6..cdf7224 100644 --- a/packages-user/client-base/src/types.ts +++ b/packages-user/client-base/src/types.ts @@ -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; /** 主界面的 UI 控制器,管理游戏主界面的 UI 显示 */ readonly mainUIController: IUIController; + /** 渲染端主按键系统 */ + readonly hotkey: IClientHotkey; } export interface IClientBaseExtended { diff --git a/packages-user/client-modules/src/client.ts b/packages-user/client-modules/src/client.ts index 5d9016a..5aeb6f3 100644 --- a/packages-user/client-modules/src/client.ts +++ b/packages-user/client-modules/src/client.ts @@ -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; readonly mainUIController: IUIController; + 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(); diff --git a/packages/common/src/logger.json b/packages/common/src/logger.json index 9f1dc9e..0c2d6de 100644 --- a/packages/common/src/logger.json +++ b/packages/common/src/logger.json @@ -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." } } diff --git a/packages/common/src/utils/func.ts b/packages/common/src/utils/func.ts index 527590d..37e1d5c 100644 --- a/packages/common/src/utils/func.ts +++ b/packages/common/src/utils/func.ts @@ -20,3 +20,27 @@ export function cumsum(seq: Iterable): 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( + 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]; +} diff --git a/packages/system/src/hotkey/command.ts b/packages/system/src/hotkey/command.ts index 6be7e4b..35af4e3 100644 --- a/packages/system/src/hotkey/command.ts +++ b/packages/system/src/hotkey/command.ts @@ -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; } } diff --git a/packages/system/src/hotkey/system.ts b/packages/system/src/hotkey/system.ts index 8224be0..e660d50 100644 --- a/packages/system/src/hotkey/system.ts +++ b/packages/system/src/hotkey/system.ts @@ -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 void>; /** 作用域条件,为 `null` 时视为始终满足 */ condition: (() => boolean) | null; - /** 作用域内已实现的按键指令 */ - handlers: Map; } -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 { listening: HTMLElement | null = null; + holding: KeyCode[] = []; /** 指令注册表 */ private commands: Map = new Map(); /** 作用域栈 */ private scopes: IHotkeyScope[] = []; - /** 当前按下的按键,按按下顺序排列 */ - private holding: KeyCode[] = []; - /** 待触发项 */ - private pending: IHotkeyPending[] = []; - /** 最近一次激励负载 */ - private now: number = 0; - /** 当前激励源控制器 */ - private controller: IExcitableController | 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 | null = null; - /** 获取当前栈顶作用域 */ + /** 当前正在按下的按键组成的集合 */ + private holdingSet: Set = new Set(); + /** 如果按键指令都是按下时触发,那么当前的按键组合下,有可能触发哪些按键 */ + private holdingCmds: Map = new Map(); + /** 需要等待 `timeout` 进行一次触发的指令集合 */ + private waitTimeout: Set = 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 { * @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 { 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 { } 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(); + this.waitTimeout.forEach(v => { + if (v.command === obj) { + toDeleteWait.add(v); + } + }); + toDeleteWait.forEach(v => { + this.waitTimeout.delete(v); + }); } - *iterateCommands(): Iterable { - yield* this.commands.values(); + iterateCommands(): Iterable { + 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): void { - if (this.controller) { - this.controller.revoke(); + excitedBy(excitation: IExcitation | 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 diff --git a/packages/system/src/hotkey/type.ts b/packages/system/src/hotkey/type.ts index 5ce6d45..e782d5b 100644 --- a/packages/system/src/hotkey/type.ts +++ b/packages/system/src/hotkey/type.ts @@ -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): void; + excitedBy(excitation: IExcitation | null): void; /** * 进入新的按键作用域 diff --git a/src/client/initialize.ts b/src/client/initialize.ts index 185b263..9a50242 100644 --- a/src/client/initialize.ts +++ b/src/client/initialize.ts @@ -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('@system/hotkey'); + if (hotkey) { + state.hotkey.loadState(hotkey, SaveCompression.NoCompression); + } } } diff --git a/src/client/misc/hotkey.ts b/src/client/misc/hotkey.ts new file mode 100644 index 0000000..695f73d --- /dev/null +++ b/src/client/misc/hotkey.ts @@ -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 +} diff --git a/src/client/misc/index.ts b/src/client/misc/index.ts new file mode 100644 index 0000000..e69de29