Stop a key getting stuck when Shift changes it while held (#227)
game-maker / gate (push) Successful in 3m8s
game-maker / suite (push) Failing after 8m37s

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:
adminandClaude Opus 5.5 committed 2026-09-24 18:06:16 +02:00
1 parent c1a6d63edb
commit ffb5bbe039
5 files changed
+79 -11

No files matched your search

+8
View File
@@ -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;
}
+2 -1
View File
@@ -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;
+3 -2
View File
@@ -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,
+23 -8
View File
@@ -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> {
+43
View File
@@ -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