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 <noreply@anthropic.com>
This commit is contained in:
1 parent
c1a6d63edb
commit
ffb5bbe039
5 files changed
+79
-11
No files matched your search
@@ -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;
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
assetRefLabel,
|
assetRefLabel,
|
||||||
NO_ASSET_LABEL,
|
NO_ASSET_LABEL,
|
||||||
} from '../../core/properties/assetRef';
|
} from '../../core/properties/assetRef';
|
||||||
|
import { keyName } from '../../core/keys';
|
||||||
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
import { FIRST_TILE_LAYER } from '../../core/model/liveTilemap';
|
||||||
import { tilemapLayerNames } from '../../core/model/tilemap';
|
import { tilemapLayerNames } from '../../core/model/tilemap';
|
||||||
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
import { isKindWith, type ReferenceKind } from '../../core/properties/kinds';
|
||||||
@@ -569,7 +570,7 @@ class KeyCaptureButton extends Button {
|
|||||||
this.capturing = true;
|
this.capturing = true;
|
||||||
return 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.setText(keyLabel(this.key));
|
||||||
this.onCapture(this.key);
|
this.onCapture(this.key);
|
||||||
return true;
|
return true;
|
||||||
|
|||||||
@@ -22,6 +22,7 @@ import {
|
|||||||
type Value,
|
type Value,
|
||||||
} from '../../core/graph';
|
} from '../../core/graph';
|
||||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||||
|
import { keyName } from '../../core/keys';
|
||||||
import { graphLookup } from '../../core/model/graphLookup';
|
import { graphLookup } from '../../core/model/graphLookup';
|
||||||
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties';
|
import { type PropertyBag, textOf, type Vec2 } from '../../core/model/Properties';
|
||||||
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
import { findAssetOf, findScene, type Project } from '../../core/model/Project';
|
||||||
@@ -436,7 +437,7 @@ export class BehaviorSystem implements System {
|
|||||||
|
|
||||||
update(world: World, dt: number): void {
|
update(world: World, dt: number): void {
|
||||||
this.syncInstances(world);
|
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;
|
const frozen = world.frozen;
|
||||||
for (const instance of this.instances) {
|
for (const instance of this.instances) {
|
||||||
if (!world.entities.includes(instance.entity)) continue; // destroyed this frame, reaped below
|
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.
|
* 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 {
|
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 stored = i.nodeState.get(node.id);
|
||||||
const { state, fires } = stepKeyRepeat(
|
const { state, fires } = stepKeyRepeat(
|
||||||
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
||||||
|
|||||||
@@ -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 /
|
* 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.
|
* 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 {
|
export class Input {
|
||||||
private readonly down = new Set<string>();
|
private readonly down = new Set<string>();
|
||||||
private readonly pressed = new Set<string>();
|
private readonly pressed = new Set<string>();
|
||||||
private readonly released = new Set<string>();
|
private readonly released = new Set<string>();
|
||||||
|
/**
|
||||||
|
* 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<string, string>();
|
||||||
/** See `attach`: while this answers false the keys belong to someone else. */
|
/** See `attach`: while this answers false the keys belong to someone else. */
|
||||||
private accepts: () => boolean = () => true;
|
private accepts: () => boolean = () => true;
|
||||||
private readonly onKeyDown = (e: KeyboardEvent) => {
|
private readonly onKeyDown = (e: KeyboardEvent) => {
|
||||||
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
if ((e.target as HTMLElement | null)?.tagName === 'INPUT') return;
|
||||||
if (!this.accepts()) return;
|
if (!this.accepts()) return;
|
||||||
this.press(e.key);
|
this.press(e.key, e.code);
|
||||||
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
|
if (e.key.startsWith('Arrow') || e.key === ' ') e.preventDefault();
|
||||||
};
|
};
|
||||||
private readonly onKeyUp = (e: KeyboardEvent) => {
|
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. */
|
/** Losing the window releases every key, so nothing stays "held" behind the user's back. */
|
||||||
private readonly onBlur = () => this.releaseAll();
|
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
|
// 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.
|
// directly, since a headless run has no window to dispatch key events on.
|
||||||
|
|
||||||
press(key: string): void {
|
press(key: string, code = ''): void {
|
||||||
if (!this.down.has(key)) this.pressed.add(key); // ignore auto-repeat
|
const name = keyName(key);
|
||||||
this.down.add(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 {
|
release(key: string, code = ''): void {
|
||||||
if (this.down.delete(key)) this.released.add(key);
|
const name = (code && this.byCode.get(code)) || keyName(key);
|
||||||
|
this.byCode.delete(code);
|
||||||
|
if (this.down.delete(name)) this.released.add(name);
|
||||||
}
|
}
|
||||||
|
|
||||||
releaseAll(): void {
|
releaseAll(): void {
|
||||||
for (const key of this.down) this.released.add(key);
|
for (const key of this.down) this.released.add(key);
|
||||||
this.down.clear();
|
this.down.clear();
|
||||||
|
this.byCode.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -57,13 +71,14 @@ export class Input {
|
|||||||
window.removeEventListener('blur', this.onBlur);
|
window.removeEventListener('blur', this.onBlur);
|
||||||
}
|
}
|
||||||
this.accepts = () => true;
|
this.accepts = () => true;
|
||||||
|
this.byCode.clear();
|
||||||
this.down.clear();
|
this.down.clear();
|
||||||
this.pressed.clear();
|
this.pressed.clear();
|
||||||
this.released.clear();
|
this.released.clear();
|
||||||
}
|
}
|
||||||
|
|
||||||
isDown(key: string): boolean {
|
isDown(key: string): boolean {
|
||||||
return this.down.has(key);
|
return this.down.has(keyName(key));
|
||||||
}
|
}
|
||||||
|
|
||||||
get justPressed(): ReadonlySet<string> {
|
get justPressed(): ReadonlySet<string> {
|
||||||
|
|||||||
@@ -126,6 +126,49 @@ describe('key events', () => {
|
|||||||
frame();
|
frame();
|
||||||
expect(world.log).toHaveLength(2);
|
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
|
// The node's settings are milliseconds, the frame lengths seconds. Those lengths are exact in
|
||||||
|
|||||||
Reference in new issue
Block a user