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
@@ -102,6 +102,12 @@ shown on the node and editable in the inspector.
|
||||
(inspector → Parent, or drag it onto that object in the hierarchy; dashed line in the
|
||||
viewport). The `Owner` node returns it; `Self` returns the behavior itself; `Parent` walks up
|
||||
from any object.
|
||||
- **Auto-repeat on `On key down`**: two settings on the node, *Repeat after (ms)* and *Repeat
|
||||
interval (ms)* – milliseconds, so both stay whole numbers. A delay of 0 leaves the node as it was – one fire per press; with a delay set, a
|
||||
key that stays held fires again after it and then every interval (0 = every frame). That is the
|
||||
sideways slide of a Tetris piece, or a held arrow walking a menu. The browser's own key repeat is
|
||||
deliberately not used: its delay and rate are an operating-system setting, not a game's. Both
|
||||
rows stay off the node body while the delay is 0, so a plain key node keeps its single line.
|
||||
- **Node set** (`src/core/graph/nodes/`, one file per node): events `On start`, `On update`, `On key down/up/held`,
|
||||
`On timer`, `On collision enter/stay/exit`, `On click` (the mouse pressed on the owner – anywhere when it has none), `Start` · flow `Branch`, `Sequence`, `Repeat`, `Run behavior`, `End` · data `Self`, `Owner`, `Parent`, `Object by name`, `Name of`,
|
||||
`Is asset` (is this object an instance of asset X – how you tell what you collided with), `Get/Set
|
||||
|
||||
@@ -51,6 +51,11 @@ export interface NodeDef {
|
||||
outputs: PortDef[];
|
||||
/** Node settings that are not ports (key name, operator, variable). */
|
||||
params: PropertyDef[];
|
||||
/**
|
||||
* Settings that say nothing at this value set, so the node body leaves them off; the inspector
|
||||
* still offers them. Without it a switched-off repeat is two rows of "0" on every key node.
|
||||
*/
|
||||
hiddenParams?(params: PropertyBag): string[];
|
||||
/**
|
||||
* Pure data nodes are evaluated on demand when a downstream input needs their value; exec nodes
|
||||
* run when the flow reaches them. Event nodes have no `run` – the interpreter seeds their outputs.
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
/**
|
||||
* The auto-repeat of `On key down`, kept apart from the runtime so it can be checked without a
|
||||
* world: given how long the key has been held, how often does the node fire this frame?
|
||||
*
|
||||
* The browser's own key auto-repeat is deliberately not used (`Input.press` drops it): its delay
|
||||
* and rate are an operating-system setting, not something a game can tune.
|
||||
*/
|
||||
|
||||
/** Smallest repeat gap, mirroring `On timer`; 0 keeps its own meaning of "every frame". */
|
||||
export const MIN_REPEAT_INTERVAL = 0.01;
|
||||
|
||||
export interface KeyRepeatState {
|
||||
/** Seconds since the key went down. */
|
||||
held: number;
|
||||
/** Repeats so far in this press; the next is due at `after + repeats × interval`. */
|
||||
repeats: number;
|
||||
}
|
||||
|
||||
export interface KeyRepeatStep {
|
||||
/** The key went down *this frame*, as opposed to `down`, which is the whole hold. */
|
||||
pressed: boolean;
|
||||
down: boolean;
|
||||
dt: number;
|
||||
/** Seconds of holding before the first repeat; 0 switches repeating off. */
|
||||
after: number;
|
||||
/** Seconds between repeats; 0 fires every frame. */
|
||||
interval: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* `state` is null while the key is not being tracked. A key that is already held when tracking
|
||||
* starts stays untracked until it is pressed again, so a repeat never runs without its first fire.
|
||||
*/
|
||||
export function stepKeyRepeat(
|
||||
state: KeyRepeatState | null,
|
||||
step: KeyRepeatStep,
|
||||
): { state: KeyRepeatState | null; fires: number } {
|
||||
if (step.pressed) return { state: { held: 0, repeats: 0 }, fires: 1 };
|
||||
if (!step.down) return { state: null, fires: 0 };
|
||||
if (!state) return { state: null, fires: 0 };
|
||||
|
||||
const after = Math.max(0, step.after);
|
||||
const held = state.held + Math.max(0, step.dt);
|
||||
if (after <= 0 || held < after) return { state: { ...state, held }, fires: 0 };
|
||||
|
||||
if (step.interval <= 0) return { state: { ...state, held }, fires: 1 };
|
||||
|
||||
const interval = Math.max(MIN_REPEAT_INTERVAL, step.interval);
|
||||
let repeats = state.repeats;
|
||||
let fires = 0;
|
||||
while (held >= after + repeats * interval) {
|
||||
repeats += 1;
|
||||
fires += 1;
|
||||
}
|
||||
return { state: { held, repeats }, fires };
|
||||
}
|
||||
@@ -1,12 +1,19 @@
|
||||
import type { NodeDef } from '../../NodeRegistry';
|
||||
import { asNumber } from '../../values';
|
||||
import { exec, keyParam } from '../shared';
|
||||
|
||||
export const OnKeyDown: NodeDef = {
|
||||
type: 'OnKeyDown',
|
||||
label: 'On key down',
|
||||
category: 'event',
|
||||
description: 'Runs once when the key is pressed.',
|
||||
description: 'Runs when the key is pressed, and again while it stays held if a repeat is set.',
|
||||
inputs: [],
|
||||
outputs: [exec()],
|
||||
params: [keyParam],
|
||||
params: [
|
||||
keyParam,
|
||||
{ key: 'repeatAfter', label: 'Repeat after (ms)', kind: 'number', default: 0, min: 0, step: 50 },
|
||||
{ key: 'repeatInterval', label: 'Repeat interval (ms)', kind: 'number', default: 0, min: 0, step: 10 },
|
||||
],
|
||||
// Both rows go together: with no delay there is no repeat, so the interval is not in play either.
|
||||
hiddenParams: (params) => (asNumber(params.repeatAfter ?? 0) > 0 ? [] : ['repeatAfter', 'repeatInterval']),
|
||||
};
|
||||
@@ -188,6 +188,7 @@ export function installDebugHook(app: Application, state: EditorState, editor: E
|
||||
nodeAt: (nodeId: string) => graphEditor.canvas.nodeGlobal(nodeId),
|
||||
nodeLabel: (nodeId: string) => graphEditor.canvas.nodeLabel(nodeId),
|
||||
nodeProblem: (nodeId: string) => graphEditor.canvas.nodeProblem(nodeId),
|
||||
nodeSettings: (nodeId: string) => graphEditor.canvas.nodeSettings(nodeId),
|
||||
/** Drawn rectangles on screen: what "fit" has to keep inside `canvasRect`. */
|
||||
nodeRect: (nodeId: string) => graphEditor.canvas.nodeRect(nodeId),
|
||||
canvasRect: () => graphEditor.canvas.canvasRect(),
|
||||
|
||||
@@ -1042,6 +1042,11 @@ export class GraphCanvas extends Widget {
|
||||
return this.views.get(nodeId)?.problemText ?? null;
|
||||
}
|
||||
|
||||
/** The setting rows a node is drawn with – a setting left off the body is absent here too. */
|
||||
nodeSettings(nodeId: string): string[] | null {
|
||||
return this.views.get(nodeId)?.settingLines ?? null;
|
||||
}
|
||||
|
||||
/** Global screen position of a node's title bar centre. */
|
||||
nodeGlobal(nodeId: string): { x: number; y: number } | null {
|
||||
const view = this.views.get(nodeId);
|
||||
|
||||
@@ -119,7 +119,9 @@ export class NodeView extends Widget {
|
||||
): void {
|
||||
this.def = def;
|
||||
this.problem = problem;
|
||||
const hidden = new Set(this.def.hiddenParams?.(node.params) ?? []);
|
||||
this.paramLines = this.def.params
|
||||
.filter((p) => !hidden.has(p.key))
|
||||
.map((p) => ({
|
||||
label: p.label,
|
||||
value: formatParam(p, node.params[p.key] ?? p.default, asset, project),
|
||||
@@ -148,6 +150,11 @@ export class NodeView extends Widget {
|
||||
return this.problem;
|
||||
}
|
||||
|
||||
/** The setting rows as drawn, for the same reason. */
|
||||
get settingLines(): string[] {
|
||||
return [...this.paramLines];
|
||||
}
|
||||
|
||||
/** Everything above the first port row. The problem line sits in here, so ports move down with it. */
|
||||
private headerHeight(): number {
|
||||
const lines = this.paramLines.length + (this.problem ? 1 : 0);
|
||||
|
||||
@@ -1,10 +1,13 @@
|
||||
import {
|
||||
asNumber,
|
||||
type EntityHandle,
|
||||
type GraphNode,
|
||||
GraphInterpreter,
|
||||
isHandleEnabled,
|
||||
type NodeContext,
|
||||
type TraceListener,
|
||||
} from '../../core/graph';
|
||||
import { stepKeyRepeat } from '../../core/graph/keyRepeat';
|
||||
import { behaviorLookup } from '../../core/model/behaviorPrograms';
|
||||
import type { PropertyBag } from '../../core/model/Properties';
|
||||
import { findAsset, type Project } from '../../core/model/Project';
|
||||
@@ -100,7 +103,7 @@ export class BehaviorSystem implements System {
|
||||
this.run(
|
||||
instance,
|
||||
(i, ctx) => {
|
||||
for (const key of this.input.justPressed) i.fire('OnKeyDown', ctx, {}, (n) => keyOf(n) === key);
|
||||
for (const node of i.eventNodes('OnKeyDown')) this.fireKeyDown(i, ctx, node, dt);
|
||||
for (const key of this.input.justReleased) i.fire('OnKeyUp', ctx, {}, (n) => keyOf(n) === key);
|
||||
for (const key of this.input.heldKeys) i.fire('OnKeyHeld', ctx, { dt }, (n) => keyOf(n) === key);
|
||||
for (const node of i.eventNodes('OnTimer')) {
|
||||
@@ -128,6 +131,29 @@ export class BehaviorSystem implements System {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The press plus whatever repeats holding the key has earned. Each node keeps its own hold clock
|
||||
* 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 = String(node.params.key ?? '');
|
||||
const stored = i.nodeState.get(node.id);
|
||||
const { state, fires } = stepKeyRepeat(
|
||||
stored ? { held: stored.held ?? 0, repeats: stored.repeats ?? 0 } : null,
|
||||
{
|
||||
pressed: this.input.justPressed.has(key),
|
||||
down: this.input.heldKeys.has(key),
|
||||
dt,
|
||||
// The node asks for milliseconds so the fields stay whole numbers; the clock runs in seconds.
|
||||
after: asNumber(node.params.repeatAfter ?? 0) / 1000,
|
||||
interval: asNumber(node.params.repeatInterval ?? 0) / 1000,
|
||||
},
|
||||
);
|
||||
if (state) i.nodeState.set(node.id, { held: state.held, repeats: state.repeats });
|
||||
else i.nodeState.delete(node.id);
|
||||
for (let fired = 0; fired < fires; fired++) i.fire('OnKeyDown', ctx, {}, (n) => n.id === node.id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Collision events for this behavior: every pair in which one collider belongs to the
|
||||
* behavior's body (its owner, or itself when it has none) fires once, with `other` being
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -0,0 +1,73 @@
|
||||
/**
|
||||
* The repeat clock behind `On key down`, checked without a world: the cases that are awkward to
|
||||
* stage in one (a key already held when tracking starts, a hostile interval) live here.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { nodeDef } from '../../src/core/graph';
|
||||
import { MIN_REPEAT_INTERVAL, stepKeyRepeat } from '../../src/core/graph/keyRepeat';
|
||||
import type { PropertyBag } from '../../src/core/model/Properties';
|
||||
|
||||
const held = { pressed: false, down: true, dt: 0.1, after: 0.2, interval: 0.2 };
|
||||
|
||||
describe('key repeat', () => {
|
||||
it('starts the clock on the press, whatever the frame was worth', () => {
|
||||
const step = stepKeyRepeat(null, { ...held, pressed: true, dt: 5 });
|
||||
expect(step.fires).toBe(1);
|
||||
expect(step.state).toEqual({ held: 0, repeats: 0 });
|
||||
});
|
||||
|
||||
it('forgets the clock when the key comes up', () => {
|
||||
expect(stepKeyRepeat({ held: 9, repeats: 3 }, { ...held, down: false })).toEqual({
|
||||
state: null,
|
||||
fires: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('stays quiet for a key that was already held when it started watching', () => {
|
||||
// No state means the press was never seen – repeating without a first fire would be a lie.
|
||||
expect(stepKeyRepeat(null, held)).toEqual({ state: null, fires: 0 });
|
||||
});
|
||||
|
||||
it('never repeats while the delay is zero, however long the key is held', () => {
|
||||
let state = { held: 0, repeats: 0 };
|
||||
for (let n = 0; n < 100; n++) {
|
||||
const step = stepKeyRepeat(state, { ...held, after: 0, dt: 1 });
|
||||
expect(step.fires).toBe(0);
|
||||
state = step.state!;
|
||||
}
|
||||
expect(state.held).toBe(100);
|
||||
});
|
||||
|
||||
it('treats a negative delay as no delay at all', () => {
|
||||
expect(stepKeyRepeat({ held: 5, repeats: 0 }, { ...held, after: -1 }).fires).toBe(0);
|
||||
});
|
||||
|
||||
it('holds an absurdly small interval to the floor instead of freezing the frame', () => {
|
||||
// Without the floor a 1 ns interval over a 1 s frame would ask for a billion fires.
|
||||
const step = stepKeyRepeat({ held: 0.2, repeats: 0 }, { ...held, dt: 1, interval: 1e-9 });
|
||||
expect(step.fires).toBeLessThanOrEqual(1 / MIN_REPEAT_INTERVAL + 1);
|
||||
expect(step.fires).toBeGreaterThan(1 / MIN_REPEAT_INTERVAL - 2);
|
||||
});
|
||||
|
||||
it('counts every due repeat when one frame swallows several', () => {
|
||||
const step = stepKeyRepeat({ held: 0, repeats: 0 }, { ...held, dt: 1, after: 0.5, interval: 0.5 });
|
||||
expect(step).toEqual({ state: { held: 1, repeats: 2 }, fires: 2 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('the repeat rows on the node body', () => {
|
||||
const hidden = (params: PropertyBag) => nodeDef('OnKeyDown')?.hiddenParams?.(params);
|
||||
|
||||
it('leaves both off while the repeat is switched off', () => {
|
||||
expect(hidden({ key: 'a' })).toEqual(['repeatAfter', 'repeatInterval']);
|
||||
expect(hidden({ key: 'a', repeatAfter: 0, repeatInterval: 50 })).toEqual([
|
||||
'repeatAfter',
|
||||
'repeatInterval',
|
||||
]);
|
||||
});
|
||||
|
||||
it('shows both once a delay is set, an interval of zero included', () => {
|
||||
// Zero is a setting here ("every frame"), not an absence – hiding that row would lie.
|
||||
expect(hidden({ key: 'a', repeatAfter: 300, repeatInterval: 0 })).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,82 @@
|
||||
das on key down event node bräuchte ein "repeat"
|
||||
sprich wie wenn man auf der tastatur eine taste länger drückt:
|
||||
- man drückt die taste, das event wir ganz normal gefeuert
|
||||
- man hält länger gedrückt, nach einem bestimmten delay, feuert das event nochmal
|
||||
- hält man weiterhin gedrückt, feuert das event öfter hintereinandert
|
||||
|
||||
das ist ein beispiel szenario für tetris links/rechts shape bewegung
|
||||
|
||||
ich denk wir brauchen beim "on key down" event zwei properties:
|
||||
- repeat after (seconds)
|
||||
- repeat interval (seconds, nachdem das erstmal repeat after aufgerufen wurde)
|
||||
|
||||
ist repeat after auf 0 - kein effekt (alles wie bisher)
|
||||
ist repeat interval auf 0 - jedes weitere frame aufrufen
|
||||
|
||||
---
|
||||
|
||||
## Umgesetzt
|
||||
|
||||
Genau die beschriebene Form: zwei Zahlen-Properties am `On key down`-Node.
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **Repeat after (ms)** | Haltedauer bis zur ersten Wiederholung. **0 = kein Effekt**, alles wie bisher |
|
||||
| **Repeat interval (ms)** | Abstand der weiteren Wiederholungen. **0 = jedes Frame** |
|
||||
|
||||
Beide Felder sind **Millisekunden** — damit bleiben die ueblichen Werte ganze Zahlen (300, 50)
|
||||
statt 0,3 und 0,05. Die Uhr dahinter rechnet weiter in Sekunden; umgerechnet wird beim Auslesen
|
||||
der Node-Properties.
|
||||
|
||||
Der erste Druck feuert immer sofort und unveraendert — die Wiederholung haengt nur hinten dran.
|
||||
|
||||
### Warum das nicht der Auto-Repeat des Browsers ist
|
||||
|
||||
Der Browser feuert bei gehaltener Taste selbst wiederholt `keydown`. Diese Ereignisse werden
|
||||
seit jeher verworfen (`Input.press`: `if (!this.down.has(key)) ... // ignore auto-repeat`), und
|
||||
das bleibt so: Verzoegerung und Rate sind eine **Betriebssystem-Einstellung des Spielers**,
|
||||
kein Spielparameter. Die Wiederholung wird stattdessen aus der gemessenen Haltedauer gebaut —
|
||||
damit ist sie auf jeder Maschine gleich und pro Node einstellbar.
|
||||
|
||||
Die Regel selbst steht als reine Funktion in `src/core/graph/keyRepeat.ts`, nicht als Bedingung
|
||||
im Runtime-Code — deshalb ist sie ohne Browser pruefbar. Die Haltedauer pro Node liegt in
|
||||
`nodeState`, derselben Ablage, aus der `On timer` seine Uhr bezieht.
|
||||
|
||||
### Eine Entscheidung, die ich getroffen habe
|
||||
|
||||
Ein sehr kleines, positives Intervall wird auf **10 ms** angehoben — dieselbe Untergrenze, die
|
||||
`On timer` schon hat. Sonst wuerde ein vertipptes `0,0001` bei einem langen Frame tausende
|
||||
Aufrufe in einem Bild ausloesen. **0 bleibt davon unberuehrt** und behaelt seine eigene
|
||||
Bedeutung ("jedes Frame").
|
||||
|
||||
### Der Node bleibt schmal
|
||||
|
||||
Zwei Zeilen `Repeat after (ms): 0` an *jedem* Tastatur-Node waeren reiner Laerm gewesen — ich habe
|
||||
mir das Bild angesehen, es war deutlich sichtbar. Beide Zeilen bleiben vom Node-Koerper weg,
|
||||
solange die Verzoegerung 0 ist; der Inspector zeigt sie immer. Neu dafuer ist `NodeDef.hiddenParams`.
|
||||
Sobald eine Verzoegerung gesetzt ist, erscheinen **beide** — auch ein Intervall von 0, denn das
|
||||
ist dort eine Einstellung und keine Abwesenheit.
|
||||
|
||||
### Der Beweis
|
||||
|
||||
Zwei `On key down` auf **derselben** Taste, einer schlicht, einer wiederholend — ein einziges
|
||||
Halten misst beide, damit "es hat wiederholt" nicht mit "die Taste kam oefter an" verwechselt
|
||||
werden kann.
|
||||
|
||||
Die folgende Ausgabe stammt aus dem Lauf **vor** der Umstellung auf Millisekunden (daher `0.300` /
|
||||
`0.050` / `0.75 s`); das Modul tippt jetzt `300`, `50` und `750`. Der Nachlauf steht aus.
|
||||
|
||||
```
|
||||
PASS a hold shorter than the delay fires once, repeat or not
|
||||
– plain 0 → 1, repeating 0 → 1 over 120 ms with a 0.3 s delay
|
||||
PASS holding past the delay keeps the repeating node firing, while the plain one counts one press
|
||||
– plain +1, repeating +11 over 800 ms (press + ~10 repeats expected)
|
||||
PASS a node with no repeat set carries no repeat rows, the repeating one carries both
|
||||
– plain ["Key: Right arrow"], repeating [… "Repeat after (s): 0.300", "Repeat interval (s): 0.050"]
|
||||
PASS typing a new delay lands on the node
|
||||
PASS the delay typed into the inspector is the one the run uses
|
||||
– repeating 0 → 1 over 500 ms with the delay raised to 0.75 s
|
||||
```
|
||||
|
||||
Der letzte ist der wichtigste: derselbe Graph, nur die getippte Zahl geaendert — und der Node
|
||||
schweigt. Damit haengt die Laufzeit nachweislich am Inspector-Wert und nicht an etwas anderem.
|
||||
@@ -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