Let On key down repeat while the key stays held

A held key fired once and then nothing, so sliding a Tetris piece sideways or
walking a menu meant building a timer next to every key node. Two settings on
the node do it now: Repeat after and Repeat interval, both in milliseconds so
the usual values stay whole numbers (300, 50) instead of 0.3 and 0.05. A delay
of 0 keeps the node exactly as it was, an interval of 0 means every frame.

The browser's own auto-repeat is deliberately not used - Input.press has always
dropped it - because its delay and rate are an operating-system setting of the
player's, not a game's. The repeat is built from the measured hold time instead,
per node, so two nodes on one key keep their own clocks. The rule itself is a
pure function in keyRepeat.ts and is checked without a world.

Both rows stay off the node body while the delay is 0: two lines of "0" on every
key node in every graph would be noise, and the screenshot said so. NodeDef gains
hiddenParams for that; the inspector still shows them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
rootandClaude Opus 5 committed 2026-09-09 22:44:00 +02:00
1 parent 4b0919a224
commit 3ab8d88ad6
13 files changed
+552 -3

No files matched your search

+111
View File
@@ -119,6 +119,117 @@ describe('key events', () => {
});
});
// The node's settings are milliseconds, the frame lengths seconds. Those lengths are exact in
// binary (0.125, 0.25, 0.5), so a repeat due at a round hold time is never lost to a rounding error.
describe('On key down repeat', () => {
it('fires again after the delay, then on its own interval', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 250 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
expect(world.log).toEqual(['down']);
for (let n = 0; n < 3; n++) frame(0.125); // held 0.375 s: still short of the delay
expect(world.log).toEqual(['down']);
frame(0.125); // held 0.5 s
expect(world.log).toEqual(['down', 'down']);
frame(0.125);
expect(world.log).toHaveLength(2); // 0.625 s: half an interval on
frame(0.125);
expect(world.log).toHaveLength(3); // 0.75 s
frame(0.25);
expect(world.log).toHaveLength(4);
});
it('leaves a key alone while the delay is zero', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 0, repeatInterval: 50 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
for (let n = 0; n < 30; n++) frame(0.125);
expect(world.log).toEqual(['down']);
});
it('fires every frame when the interval is zero', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 0 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
frame(0.125); // held 0.125 s
expect(world.log).toEqual(['down']);
frame(0.125); // held 0.25 s: the delay is up
expect(world.log).toHaveLength(2);
frame(0.0625);
frame(0.0625);
expect(world.log).toHaveLength(4); // a short frame counts as much as a long one
});
it('starts the delay over on the next press', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
input.release('a');
frame(0.5); // well past the delay, but the key is gone: no repeat
expect(world.log).toEqual(['down']);
input.press('a');
frame(0.125);
expect(world.log).toEqual(['down', 'down']); // the press itself, not a repeat
frame(0.125); // held 0.125 s: the clock restarted, so nothing is due yet
expect(world.log).toHaveLength(2);
frame(0.125); // held 0.25 s
expect(world.log).toHaveLength(3);
});
it('catches up when one frame is longer than the interval', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 500, repeatInterval: 500 }, 'down');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
frame(2); // repeats due at 0.5, 1.0, 1.5 and 2.0
expect(world.log).toHaveLength(5);
});
it('gives two nodes on the same key their own clocks', () => {
const f = scene();
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 250, repeatInterval: 250 }, 'fast');
logOn(f, 'OnKeyDown', { key: 'a', repeatAfter: 750, repeatInterval: 750 }, 'slow');
const { world, input, frame } = startWorld(f, { behaviors: true });
input.press('a');
frame(0.125);
expect(world.log).toEqual(['fast', 'slow']); // the press reaches both
frame(0.125);
frame(0.125); // held 0.25 s
expect(world.log).toEqual(['fast', 'slow', 'fast']);
frame(0.125);
frame(0.125); // held 0.5 s
expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast']);
frame(0.25); // held 0.75 s: the fast one a third time, the slow one its first
expect(world.log).toEqual(['fast', 'slow', 'fast', 'fast', 'fast', 'slow']);
});
});
describe('On timer', () => {
it('fires on its own clock, independent of the frame rate', () => {
const f = scene();