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:
1 parent
4b0919a224
commit
3ab8d88ad6
13 files changed
+552
-3
No files matched your search
@@ -0,0 +1,168 @@
|
||||
import { edge, node } from '../lib/builders.mjs';
|
||||
|
||||
/**
|
||||
* The auto-repeat of `On key down`: a held key fires again after a delay and then on its own
|
||||
* interval, which is what lets a Tetris piece slide sideways on one press.
|
||||
*
|
||||
* The step brings its own box and behavior with *two* `On key down` nodes on the same key – one
|
||||
* plain, one repeating. One hold then measures both at once, so "it repeated" cannot be confused
|
||||
* with "the key arrived more often than expected". Everything it adds is removed again at the end.
|
||||
*/
|
||||
|
||||
const PLAIN = 'plain hit';
|
||||
const REPEATING = 'repeating hit';
|
||||
const DELAY = 300;
|
||||
const INTERVAL = 50;
|
||||
|
||||
const repeatGraph = {
|
||||
nodes: [
|
||||
node('r-plain', 'OnKeyDown', 0, 0, { key: 'ArrowRight' }),
|
||||
node('r-plain-log', 'Log', 340, 0, { message: PLAIN }),
|
||||
node('r-repeat', 'OnKeyDown', 0, 220, {
|
||||
key: 'ArrowRight',
|
||||
repeatAfter: DELAY,
|
||||
repeatInterval: INTERVAL,
|
||||
}),
|
||||
node('r-repeat-log', 'Log', 340, 220, { message: REPEATING }),
|
||||
],
|
||||
edges: [
|
||||
edge('r-e1', 'r-plain', 'exec', 'r-plain-log', 'exec'),
|
||||
edge('r-e2', 'r-repeat', 'exec', 'r-repeat-log', 'exec'),
|
||||
],
|
||||
};
|
||||
|
||||
export async function keyRepeat(t) {
|
||||
const { check, sleep, json, shot, click, holdKey, world, mutate, stop, select, typeText } = t;
|
||||
/** Both counts out of one read of the world, so the two are always of the same moment. */
|
||||
const hits = async () => {
|
||||
const log = (await world()).log;
|
||||
const count = (message) => log.filter((line) => line.includes(message)).length;
|
||||
return { plain: count(PLAIN), repeating: count(REPEATING) };
|
||||
};
|
||||
|
||||
// 20a. A box with the two-node behavior below it, written into the model directly: what is under
|
||||
// test is the repeat, not another way of building a graph.
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
const proto = scene.objects[0];
|
||||
const a = structuredClone(p.assets.find((x) => x.id === 'mover'));
|
||||
Object.assign(a, {
|
||||
id: 'repeater',
|
||||
name: 'Repeater',
|
||||
props: { enabled: true, runInEditor: false },
|
||||
variables: [],
|
||||
graph: ${JSON.stringify(repeatGraph)},
|
||||
});
|
||||
p.assets.push(a);
|
||||
const make = (id, assetId, parentId, y) => {
|
||||
const o = structuredClone(proto);
|
||||
Object.assign(o, {
|
||||
id,
|
||||
assetId,
|
||||
name: id === 'r-box' ? 'Repeat box' : 'Repeater',
|
||||
position: { x: 0, y, z: 0 },
|
||||
parentId,
|
||||
overrides: {},
|
||||
instanceEdits: {},
|
||||
});
|
||||
return o;
|
||||
};
|
||||
scene.objects.push(make('r-box', 'crate', '', -300), make('r-logic', 'repeater', 'r-box', 0));
|
||||
`);
|
||||
await sleep(400);
|
||||
|
||||
await t.play();
|
||||
await sleep(300);
|
||||
|
||||
// 20b. A hold shorter than the delay is still just a press. Without this the check below would
|
||||
// also pass on a node that simply fires every frame the key is down.
|
||||
const beforeShort = await hits();
|
||||
await holdKey('ArrowRight', 120);
|
||||
await sleep(200);
|
||||
const afterShort = await hits();
|
||||
check(
|
||||
'a hold shorter than the delay fires once, repeat or not',
|
||||
afterShort.plain === beforeShort.plain + 1 && afterShort.repeating === beforeShort.repeating + 1,
|
||||
`plain ${beforeShort.plain} → ${afterShort.plain}, repeating ${beforeShort.repeating} → ${afterShort.repeating} over 120 ms with a ${DELAY} ms delay`,
|
||||
);
|
||||
|
||||
// 20c. Hold it past the delay: the plain node still counts one press, the repeating one runs.
|
||||
await holdKey('ArrowRight', 800);
|
||||
await sleep(200);
|
||||
const afterLong = await hits();
|
||||
const gained = afterLong.repeating - afterShort.repeating;
|
||||
check(
|
||||
'holding past the delay keeps the repeating node firing, while the plain one counts one press',
|
||||
afterLong.plain === afterShort.plain + 1 && gained > 4 && gained < 25,
|
||||
`plain +${afterLong.plain - afterShort.plain}, repeating +${gained} over 800 ms (press + ~${Math.round((800 - DELAY) / INTERVAL)} repeats expected)`,
|
||||
);
|
||||
await stop();
|
||||
await sleep(300);
|
||||
|
||||
// 20d. Both settings are on the node itself, so a graph can be tuned where it is built.
|
||||
await select({ kind: 'node', assetId: 'repeater', nodeId: 'r-repeat' });
|
||||
await sleep(300);
|
||||
const afterField = await json(`__gm.inspectorField('Repeat after (ms)')`);
|
||||
const intervalField = await json(`__gm.inspectorField('Repeat interval (ms)')`);
|
||||
check(
|
||||
'the node inspector offers the delay and the interval',
|
||||
afterField !== null && intervalField !== null,
|
||||
JSON.stringify({ afterField, intervalField }),
|
||||
);
|
||||
|
||||
// A switched-off repeat is worth no rows: two lines of "0" on every key node would make each
|
||||
// one taller for nothing. The repeating node next to it shows both, so this is not just hiding.
|
||||
const plainRows = await json(`__gm.graph.nodeSettings('r-plain')`);
|
||||
const repeatRows = await json(`__gm.graph.nodeSettings('r-repeat')`);
|
||||
check(
|
||||
'a node with no repeat set carries no repeat rows, the repeating one carries both',
|
||||
plainRows.length === 1 &&
|
||||
repeatRows.length === 3 &&
|
||||
repeatRows.some((r) => r.startsWith('Repeat after')) &&
|
||||
repeatRows.some((r) => r.startsWith('Repeat interval')),
|
||||
JSON.stringify({ plainRows, repeatRows }),
|
||||
);
|
||||
await shot('40-key-repeat-node');
|
||||
|
||||
await click(afterField.x, afterField.y);
|
||||
await typeText('750');
|
||||
await click(intervalField.x, intervalField.y);
|
||||
await sleep(200);
|
||||
const params = await json(
|
||||
`__gm.state.project.assets.find((a) => a.id === 'repeater').graph.nodes.find((n) => n.id === 'r-repeat').params`,
|
||||
);
|
||||
check(
|
||||
'typing a new delay lands on the node',
|
||||
params.repeatAfter === 750 && params.repeatInterval === INTERVAL,
|
||||
JSON.stringify(params),
|
||||
);
|
||||
|
||||
// 20e. The typed value is what the next run obeys: 750 ms is longer than this hold, so the
|
||||
// repeating node falls silent although nothing else about the graph changed.
|
||||
await t.play();
|
||||
await sleep(300);
|
||||
const beforeTuned = await hits();
|
||||
await holdKey('ArrowRight', 500);
|
||||
await sleep(200);
|
||||
const afterTuned = await hits();
|
||||
check(
|
||||
'the delay typed into the inspector is the one the run uses',
|
||||
afterTuned.repeating === beforeTuned.repeating + 1,
|
||||
`repeating ${beforeTuned.repeating} → ${afterTuned.repeating} over 500 ms with the delay raised to 750 ms`,
|
||||
);
|
||||
await stop();
|
||||
await sleep(300);
|
||||
|
||||
await mutate(`
|
||||
const p = __gm.state.project;
|
||||
const scene = __gm.state.activeScene;
|
||||
scene.objects = scene.objects.filter((o) => o.id !== 'r-box' && o.id !== 'r-logic');
|
||||
p.assets = p.assets.filter((a) => a.id !== 'repeater');
|
||||
`);
|
||||
await sleep(300);
|
||||
check(
|
||||
'the step leaves nothing of its own behind',
|
||||
(await json(`__gm.sceneObjects().filter((o) => o.id.startsWith('r-')).length`)) === 0,
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { containerTab } from './16-container.mjs';
|
||||
import { editorRun } from './17-editor-run.mjs';
|
||||
import { pause } from './18-pause.mjs';
|
||||
import { nestedBehavior } from './19-nested-behavior.mjs';
|
||||
import { keyRepeat } from './20-key-repeat.mjs';
|
||||
|
||||
export const STEPS = [
|
||||
hierarchy,
|
||||
@@ -43,4 +44,5 @@ export const STEPS = [
|
||||
editorRun,
|
||||
pause,
|
||||
nestedBehavior,
|
||||
keyRepeat,
|
||||
];
|
||||
Reference in new issue
Block a user