From ffb5bbe039d51a6a175b4375c715033350606187 Mon Sep 17 00:00:00 2001 From: MelekTaus Date: Thu, 24 Sep 2026 18:06:16 +0200 Subject: [PATCH] Stop a key getting stuck when Shift changes it while held (#227) Input remembered keys under KeyboardEvent.key. Pressing a, adding Shift and letting go sent "A" on release, which matched nothing, so "a" stayed down until the next release without Shift. - New rule keyName in src/core/keys.ts lowercases a single character. So a and A are the same key, also under Caps Lock. - Input remembers per physical key (e.code) under which name it went down and releases exactly that name. So 1 released as ! is freed too, which lowercasing alone does not cover. - On key down, up and held, Key is down and Axis input compare with the same rule. A node that already stores A reacts to a. - Recording a key in the inspector stores Shift+A as a. - Limit: holding Shift before pressing 1 still sends !, and a node on 1 does not react. Nothing gets stuck by it. Tests: - 3 new unit tests in tests/core/behavior-events.test.ts: the flow from the issue, 1 released as !, and a node on A hearing a. - Without lowercasing exactly the issue test and the A test fail. - Without the code memory exactly the 1/! test fails. - ./check.sh: 159 files, 2340 tests, CHECK EXIT 0 (before 2337). - No e2e change, the check sum stays at 1377. Co-Authored-By: Claude Opus 5.5 --- src/core/keys.ts | 8 +++++ src/editor/inspector/fieldFactory.ts | 3 +- src/runtime/systems/BehaviorSystem.ts | 5 ++-- src/runtime/systems/Input.ts | 31 ++++++++++++++----- tests/core/behavior-events.test.ts | 43 +++++++++++++++++++++++++++ 5 files changed, 79 insertions(+), 11 deletions(-) create mode 100644 src/core/keys.ts diff --git a/src/core/keys.ts b/src/core/keys.ts new file mode 100644 index 00000000..080b56be --- /dev/null +++ b/src/core/keys.ts @@ -0,0 +1,8 @@ +/** + * The name a key goes by in the graph: `KeyboardEvent.key`, with a single character lower-cased. + * Shift and Caps Lock turn "a" into "A" on the way down or up, but it is still the same key – + * without this, a key pressed as "a" and let go as "A" would stay held (#227). + */ +export function keyName(key: string): string { + return key.length === 1 ? key.toLowerCase() : key; +} diff --git a/src/editor/inspector/fieldFactory.ts b/src/editor/inspector/fieldFactory.ts index e9a7caa3..f5463057 100644 --- a/src/editor/inspector/fieldFactory.ts +++ b/src/editor/inspector/fieldFactory.ts @@ -15,6 +15,7 @@ import { assetRefLabel, NO_ASSET_LABEL, } from '../../core/properties/assetRef'; +import { keyName } from '../../core/keys'; import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap'; import { tilemapLayerNames } from '../../core/model/tilemap'; import { isKindWith, type ReferenceKind } from '../../core/properties/kinds'; @@ -569,7 +570,7 @@ class KeyCaptureButton extends Button { this.capturing = true; return true; } - this.key = event.key; + this.key = keyName(event.key); // Shift + A is "a", the name the game hears it by this.setText(keyLabel(this.key)); this.onCapture(this.key); return true; diff --git a/src/runtime/systems/BehaviorSystem.ts b/src/runtime/systems/BehaviorSystem.ts index 2fdfed75..fc901b2a 100644 --- a/src/runtime/systems/BehaviorSystem.ts +++ b/src/runtime/systems/BehaviorSystem.ts @@ -22,6 +22,7 @@ import { type Value, } from '../../core/graph'; import { stepKeyRepeat } from '../../core/graph/keyRepeat'; +import { keyName } from '../../core/keys'; import { graphLookup } from '../../core/model/graphLookup'; import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties'; import { findAssetOf, findScene, type Project } from '../../core/model/Project'; @@ -436,7 +437,7 @@ export class BehaviorSystem implements System { update(world: World, dt: number): void { this.syncInstances(world); - const keyOf = (node: { params: PropertyBag }) => textOf(node.params.key ?? ''); + const keyOf = (node: { params: PropertyBag }) => keyName(textOf(node.params.key ?? '')); const frozen = world.frozen; for (const instance of this.instances) { if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below @@ -490,7 +491,7 @@ export class BehaviorSystem implements System { * in `nodeState`, so two nodes on the same key repeat on their own schedules. */ private fireKeyDown(i: GraphInterpreter, ctx: NodeContext, node: GraphNode, dt: number): void { - const key = textOf(node.params.key ?? ''); + const key = keyName(textOf(node.params.key ?? '')); const stored = i.nodeState.get(node.id); const { state, fires } = stepKeyRepeat( stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null, diff --git a/src/runtime/systems/Input.ts b/src/runtime/systems/Input.ts index 3ed99ee0..9e9d5ab8 100644 --- a/src/runtime/systems/Input.ts +++ b/src/runtime/systems/Input.ts @@ -1,21 +1,30 @@ +import { keyName } from '../../core/keys'; + /** * Keyboard state tracker for a running world. Records which keys are held and which went down / * up since the last `endFrame()`, so systems can react to edges as well as to held keys. + * Keys go by `keyName`, so "a" and "A" are one key. */ export class Input { private readonly down = new Set(); private readonly pressed = new Set(); private readonly released = new Set(); + /** + * The name each physical key (`KeyboardEvent.code`) went down as. A modifier changed in between + * can give the release another name ("1" down, "!" up with Shift); the key it lets go of is still + * the one it pressed. + */ + private readonly byCode = new Map(); /** See `attach`: while this answers false the keys belong to someone else. */ private accepts: () => boolean = () => true; private readonly onKeyDown = (e: KeyboardEvent) => { if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return; if (!this.accepts()) return; - this.press(e.key); + this.press(e.key, e.code); if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault(); }; private readonly onKeyUp = (e: KeyboardEvent) => { - if (this.accepts()) this.release(e.key); + if (this.accepts()) this.release(e.key, e.code); }; /** Losing the window releases every key, so nothing stays "held" behind the user's back. */ private readonly onBlur = () => this.releaseAll(); @@ -23,18 +32,23 @@ export class Input { // The state machine below is what the DOM handlers above drive – and what tests drive // directly, since a headless run has no window to dispatch key events on. - press(key: string): void { - if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat - this.down.add(key); + press(key: string, code = ''): void { + const name = keyName(key); + if (code) this.byCode.set(code, name); + if (!this.down.has(name)) this.pressed.add(name); // ignore auto-repeat + this.down.add(name); } - release(key: string): void { - if (this.down.delete(key)) this.released.add(key); + release(key: string, code = ''): void { + const name = (code && this.byCode.get(code)) || keyName(key); + this.byCode.delete(code); + if (this.down.delete(name)) this.released.add(name); } releaseAll(): void { for (const key of this.down) this.released.add(key); this.down.clear(); + this.byCode.clear(); } /** @@ -57,13 +71,14 @@ export class Input { window.removeEventListener('blur', this.onBlur); } this.accepts = () => true; + this.byCode.clear(); this.down.clear(); this.pressed.clear(); this.released.clear(); } isDown(key: string): boolean { - return this.down.has(key); + return this.down.has(keyName(key)); } get justPressed(): ReadonlySet { diff --git a/tests/core/behavior-events.test.ts b/tests/core/behavior-events.test.ts index a50a05b4..85c22e72 100644 --- a/tests/core/behavior-events.test.ts +++ b/tests/core/behavior-events.test.ts @@ -126,6 +126,49 @@ describe('key events', () => { frame(); expect(world.log).toHaveLength(2); }); + + it('a key pressed as "a" and let go as "A" under Shift is released (#227)', () => { + const f = scene(); + logOn(f, 'OnKeyUp', { key: 'a' }, 'up'); + logOn(f, 'OnKeyHeld', { key: 'a' }, 'held'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a', 'KeyA'); + frame(); + input.press('Shift', 'ShiftLeft'); + input.press('A', 'KeyA'); // auto-repeat, now with Shift: still the same key + frame(); + input.release('A', 'KeyA'); + frame(); + input.release('Shift', 'ShiftLeft'); + frame(); + expect(world.log).toEqual(['held', 'held', 'up']); + expect(input.isDown('a')).toBe(false); + }); + + it('a key keeps the name it went down as, even when Shift gives its release another one', () => { + const f = scene(); + logOn(f, 'OnKeyUp', { key: '1' }, 'up'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('1', 'Digit1'); + frame(); + input.release('!', 'Digit1'); + frame(); + expect(world.log).toEqual(['up']); + expect(input.heldKeys.size).toBe(0); + }); + + it('a key stored upper-case in the graph still hears the key', () => { + const f = scene(); + logOn(f, 'OnKeyDown', { key: 'A' }, 'down'); + const { world, input, frame } = startWorld(f, { behaviors: true }); + + input.press('a', 'KeyA'); + frame(); + expect(world.log).toEqual(['down']); + expect(input.isDown('A')).toBe(true); + }); }); // The node's settings are milliseconds, the frame lengths seconds. Those lengths are exact in